From 0201891efa2d27341603d58b0375f4264cec721d Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 1 Jun 2026 17:52:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=92=AD=E6=94=BE=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/playrecords/route.ts | 45 +- src/components/PlayRecordsPanel.tsx | 204 +++++- src/lib/d1.db.ts | 931 +++++++++++++++++++++------- src/lib/db.client.ts | 300 +++++++-- src/lib/db.ts | 310 +++++++-- src/lib/postgres.db.ts | 911 ++++++++++++++++++++------- src/lib/redis-base.db.ts | 776 +++++++++++++++++------ src/lib/types.ts | 72 ++- 8 files changed, 2721 insertions(+), 828 deletions(-) diff --git a/src/app/api/playrecords/route.ts b/src/app/api/playrecords/route.ts index dd5eeca..4cadcb1 100644 --- a/src/app/api/playrecords/route.ts +++ b/src/app/api/playrecords/route.ts @@ -29,14 +29,18 @@ export async function GET(request: NextRequest) { // 检查播放记录迁移标识,没有迁移标识时执行迁移 if (!userInfoV2.playrecord_migrated) { - console.log(`用户 ${authInfo.username} 播放记录未迁移,开始执行迁移...`); + console.log( + `用户 ${authInfo.username} 播放记录未迁移,开始执行迁移...` + ); await db.migratePlayRecords(authInfo.username); } } else { // 站长也需要执行迁移(站长可能不在数据库中,直接尝试迁移) const userInfoV2 = await db.getUserInfoV2(authInfo.username); if (!userInfoV2 || !userInfoV2.playrecord_migrated) { - console.log(`站长 ${authInfo.username} 播放记录未迁移,开始执行迁移...`); + console.log( + `站长 ${authInfo.username} 播放记录未迁移,开始执行迁移...` + ); await db.migratePlayRecords(authInfo.username); } } @@ -106,9 +110,11 @@ export async function POST(request: NextRequest) { await db.savePlayRecord(authInfo.username, source, id, finalRecord); // 异步清理旧的播放记录(不阻塞响应) - (db as any).storage.cleanupOldPlayRecords(authInfo.username).catch((err: Error) => { - console.error('异步清理播放记录失败:', err); - }); + (db as any).storage + .cleanupOldPlayRecords(authInfo.username) + .catch((err: Error) => { + console.error('异步清理播放记录失败:', err); + }); return NextResponse.json({ success: true }, { status: 200 }); } catch (err) { @@ -142,6 +148,23 @@ export async function DELETE(request: NextRequest) { const username = authInfo.username; const { searchParams } = new URL(request.url); const key = searchParams.get('key'); + let keys: string[] | undefined; + + try { + const text = await request.text(); + if (text) { + const body = JSON.parse(text); + if (Array.isArray(body?.keys)) { + keys = Array.from( + new Set( + body.keys.filter((item: unknown) => typeof item === 'string') + ) + ); + } + } + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } if (key) { // 如果提供了 key,删除单条播放记录 @@ -154,6 +177,18 @@ export async function DELETE(request: NextRequest) { } await db.deletePlayRecord(username, source, id); + } else if (keys && keys.length > 0) { + for (const item of keys) { + const [source, id] = item.split('+'); + if (!source || !id) { + return NextResponse.json( + { error: 'Invalid key format' }, + { status: 400 } + ); + } + } + + await db.deletePlayRecords(username, keys); } else { // 未提供 key,则清空全部播放记录 // 目前 DbManager 没有对应方法,这里直接遍历删除 diff --git a/src/components/PlayRecordsPanel.tsx b/src/components/PlayRecordsPanel.tsx index 1090b9e..f701c52 100644 --- a/src/components/PlayRecordsPanel.tsx +++ b/src/components/PlayRecordsPanel.tsx @@ -1,12 +1,13 @@ 'use client'; -import { AlertTriangle, History, X } from 'lucide-react'; +import { AlertTriangle, Check, History, Trash2, X } from 'lucide-react'; import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import type { PlayRecord } from '@/lib/db.client'; import { clearAllPlayRecords, + deletePlayRecords, getAllPlayRecords, subscribeToDataUpdates, } from '@/lib/db.client'; @@ -39,6 +40,11 @@ export default function PlayRecordsPanel({ const [playRecords, setPlayRecords] = useState([]); const [loading, setLoading] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [editMode, setEditMode] = useState(false); + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + const [showDeleteSelectedDialog, setShowDeleteSelectedDialog] = + useState(false); + const [deletingSelected, setDeletingSelected] = useState(false); const loadPlayRecords = async () => { setLoading(true); @@ -51,6 +57,13 @@ export default function PlayRecordsPanel({ })) .sort((a, b) => b.save_time - a.save_time); setPlayRecords(sorted); + setSelectedKeys((prev) => { + if (prev.size === 0) return prev; + const availableKeys = new Set(sorted.map((record) => record.key)); + return new Set( + Array.from(prev).filter((key) => availableKeys.has(key)) + ); + }); } catch (error) { console.error('加载播放记录失败:', error); setPlayRecords([]); @@ -63,12 +76,59 @@ export default function PlayRecordsPanel({ try { await clearAllPlayRecords(); setPlayRecords([]); + setSelectedKeys(new Set()); + setEditMode(false); setShowConfirmDialog(false); } catch (error) { console.error('清空播放记录失败:', error); } }; + const toggleEditMode = () => { + setEditMode((prev) => { + if (prev) { + setSelectedKeys(new Set()); + } + return !prev; + }); + }; + + const toggleSelected = (key: string) => { + setSelectedKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + + const selectAll = () => { + setSelectedKeys(new Set(playRecords.map((record) => record.key))); + }; + + const handleDeleteSelected = async () => { + if (selectedKeys.size === 0) return; + + setDeletingSelected(true); + try { + const keysToDelete = Array.from(selectedKeys); + await deletePlayRecords(keysToDelete); + setPlayRecords((prev) => + prev.filter((record) => !selectedKeys.has(record.key)) + ); + setSelectedKeys(new Set()); + setEditMode(false); + setShowDeleteSelectedDialog(false); + } catch (error) { + console.error('删除选中播放记录失败:', error); + } finally { + setDeletingSelected(false); + } + }; + useEffect(() => { if (!isOpen) return; loadPlayRecords(); @@ -86,6 +146,13 @@ export default function PlayRecordsPanel({ })) .sort((a, b) => b.save_time - a.save_time); setPlayRecords(sorted); + setSelectedKeys((prev) => { + if (prev.size === 0) return prev; + const availableKeys = new Set(sorted.map((record) => record.key)); + return new Set( + Array.from(prev).filter((key) => availableKeys.has(key)) + ); + }); } ); @@ -94,6 +161,10 @@ export default function PlayRecordsPanel({ }; }, [isOpen]); + const selectedCount = selectedKeys.size; + const allSelected = + playRecords.length > 0 && selectedCount === playRecords.length; + return ( <>
- {playRecords.length > 0 && ( - - )} + {playRecords.length > 0 && + (editMode ? ( + <> + + + + + ) : ( + <> + + + + ))} + )}
); })} @@ -224,6 +358,56 @@ export default function PlayRecordsPanel({
, document.body )} + + {showDeleteSelectedDialog && + createPortal( +
+ !deletingSelected && setShowDeleteSelectedDialog(false) + } + > +
e.stopPropagation()} + > +
+
+
+ +
+
+

+ 删除播放记录 +

+

+ 确定要删除选中的 {selectedCount}{' '} + 条播放记录吗?此操作不可恢复。 +

+
+
+ +
+ + +
+
+
+
, + document.body + )} ); } diff --git a/src/lib/d1.db.ts b/src/lib/d1.db.ts index 957fff3..8937edb 100644 --- a/src/lib/d1.db.ts +++ b/src/lib/d1.db.ts @@ -19,7 +19,11 @@ import { AdminConfig } from './admin.types'; import { MangaReadRecord, MangaShelfItem } from './manga.types'; import { BookReadRecord, BookShelfItem } from './book.types'; import { DatabaseAdapter } from './d1-adapter'; -import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2'; +import { + MusicV2HistoryRecord, + MusicV2PlaylistItem, + MusicV2PlaylistRecord, +} from './music-v2'; import { userInfoCache } from './user-cache'; /** @@ -58,8 +62,15 @@ export class D1Storage implements IStorage { for (const statement of statements) { try { const result = await this.db.prepare(statement).run(); - if (!result.success && result.error && !/duplicate column|already exists/i.test(result.error)) { - console.warn('D1Storage.ensureMangaShelfColumns warning:', result.error); + if ( + !result.success && + result.error && + !/duplicate column|already exists/i.test(result.error) + ) { + console.warn( + 'D1Storage.ensureMangaShelfColumns warning:', + result.error + ); } } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -72,7 +83,10 @@ export class D1Storage implements IStorage { // ==================== 播放记录 ==================== - async getPlayRecord(userName: string, key: string): Promise { + async getPlayRecord( + userName: string, + key: string + ): Promise { try { const result = await this.db .prepare('SELECT * FROM play_records WHERE username = ? AND key = ?') @@ -87,10 +101,15 @@ export class D1Storage implements IStorage { } } - async setPlayRecord(userName: string, key: string, record: PlayRecord): Promise { + async setPlayRecord( + userName: string, + key: string, + record: PlayRecord + ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO play_records ( username, key, title, source_name, cover, year, episode_index, total_episodes, play_time, total_time, @@ -109,7 +128,8 @@ export class D1Storage implements IStorage { save_time = excluded.save_time, search_title = excluded.search_title, new_episodes = excluded.new_episodes - `) + ` + ) .bind( userName, key, @@ -132,10 +152,14 @@ export class D1Storage implements IStorage { } } - async getAllPlayRecords(userName: string): Promise<{ [key: string]: PlayRecord }> { + async getAllPlayRecords( + userName: string + ): Promise<{ [key: string]: PlayRecord }> { try { const results = await this.db - .prepare('SELECT * FROM play_records WHERE username = ? ORDER BY save_time DESC') + .prepare( + 'SELECT * FROM play_records WHERE username = ? ORDER BY save_time DESC' + ) .bind(userName) .all(); @@ -165,14 +189,37 @@ export class D1Storage implements IStorage { } } + async deletePlayRecords(userName: string, keys: string[]): Promise { + const uniqueKeys = Array.from(new Set(keys)).filter(Boolean); + if (uniqueKeys.length === 0) return; + + try { + const placeholders = uniqueKeys.map(() => '?').join(','); + await this.db + .prepare( + `DELETE FROM play_records WHERE username = ? AND key IN (${placeholders})` + ) + .bind(userName, ...uniqueKeys) + .run(); + } catch (err) { + console.error('D1Storage.deletePlayRecords error:', err); + throw err; + } + } + async cleanupOldPlayRecords(userName: string): Promise { try { - const maxRecords = parseInt(process.env.MAX_PLAY_RECORDS_PER_USER || '100', 10); + const maxRecords = parseInt( + process.env.MAX_PLAY_RECORDS_PER_USER || '100', + 10 + ); const threshold = maxRecords + 10; // 检查记录数量 const countResult = await this.db - .prepare('SELECT COUNT(*) as count FROM play_records WHERE username = ?') + .prepare( + 'SELECT COUNT(*) as count FROM play_records WHERE username = ?' + ) .bind(userName) .first(); @@ -181,7 +228,8 @@ export class D1Storage implements IStorage { // 删除超出限制的旧记录 await this.db - .prepare(` + .prepare( + ` DELETE FROM play_records WHERE username = ? AND key NOT IN ( @@ -190,11 +238,14 @@ export class D1Storage implements IStorage { ORDER BY save_time DESC LIMIT ? ) - `) + ` + ) .bind(userName, userName, maxRecords) .run(); - console.log(`D1Storage: Cleaned up old play records for user ${userName}`); + console.log( + `D1Storage: Cleaned up old play records for user ${userName}` + ); } catch (err) { console.error('D1Storage.cleanupOldPlayRecords error:', err); throw err; @@ -234,10 +285,15 @@ export class D1Storage implements IStorage { } } - async setFavorite(userName: string, key: string, favorite: Favorite): Promise { + async setFavorite( + userName: string, + key: string, + favorite: Favorite + ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO favorites ( username, key, source_name, total_episodes, title, year, cover, save_time, search_title, origin, @@ -255,7 +311,8 @@ export class D1Storage implements IStorage { origin = excluded.origin, is_completed = excluded.is_completed, vod_remarks = excluded.vod_remarks - `) + ` + ) .bind( userName, key, @@ -277,10 +334,14 @@ export class D1Storage implements IStorage { } } - async getAllFavorites(userName: string): Promise<{ [key: string]: Favorite }> { + async getAllFavorites( + userName: string + ): Promise<{ [key: string]: Favorite }> { try { const results = await this.db - .prepare('SELECT * FROM favorites WHERE username = ? ORDER BY save_time DESC') + .prepare( + 'SELECT * FROM favorites WHERE username = ? ORDER BY save_time DESC' + ) .bind(userName) .all(); @@ -330,7 +391,9 @@ export class D1Storage implements IStorage { async getMusicPlayRecord(userName: string, key: string): Promise { try { const result = await this.db - .prepare('SELECT * FROM music_play_records WHERE username = ? AND key = ?') + .prepare( + 'SELECT * FROM music_play_records WHERE username = ? AND key = ?' + ) .bind(userName, key) .first(); @@ -353,10 +416,15 @@ export class D1Storage implements IStorage { } } - async setMusicPlayRecord(userName: string, key: string, record: any): Promise { + async setMusicPlayRecord( + userName: string, + key: string, + record: any + ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO music_play_records (username, key, platform, song_id, name, artist, album, pic, play_time, duration, save_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(username, key) DO UPDATE SET @@ -367,7 +435,8 @@ export class D1Storage implements IStorage { play_time = excluded.play_time, duration = excluded.duration, save_time = excluded.save_time - `) + ` + ) .bind( userName, key, @@ -388,15 +457,18 @@ export class D1Storage implements IStorage { } } - async batchSetMusicPlayRecords(userName: string, records: { key: string; record: any }[]): Promise { + async batchSetMusicPlayRecords( + userName: string, + records: { key: string; record: any }[] + ): Promise { if (records.length === 0) return; if (!this.db) return; try { // 使用批量插入,D1 支持 batch 操作 const statements = records.map(({ key, record }) => - this.db! - .prepare(` + this.db!.prepare( + ` INSERT INTO music_play_records (username, key, platform, song_id, name, artist, album, pic, play_time, duration, save_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(username, key) DO UPDATE SET @@ -409,20 +481,20 @@ export class D1Storage implements IStorage { play_time = excluded.play_time, duration = excluded.duration, save_time = excluded.save_time - `) - .bind( - userName, - key, - record.platform, - record.id, - record.name, - record.artist, - record.album || null, - record.pic || null, - record.play_time, - record.duration, - record.save_time - ) + ` + ).bind( + userName, + key, + record.platform, + record.id, + record.name, + record.artist, + record.album || null, + record.pic || null, + record.play_time, + record.duration, + record.save_time + ) ); if (this.db.batch) { @@ -434,10 +506,14 @@ export class D1Storage implements IStorage { } } - async getAllMusicPlayRecords(userName: string): Promise<{ [key: string]: any }> { + async getAllMusicPlayRecords( + userName: string + ): Promise<{ [key: string]: any }> { try { const results = await this.db - .prepare('SELECT * FROM music_play_records WHERE username = ? ORDER BY save_time DESC') + .prepare( + 'SELECT * FROM music_play_records WHERE username = ? ORDER BY save_time DESC' + ) .bind(userName) .all(); @@ -467,7 +543,9 @@ export class D1Storage implements IStorage { async deleteMusicPlayRecord(userName: string, key: string): Promise { try { await this.db - .prepare('DELETE FROM music_play_records WHERE username = ? AND key = ?') + .prepare( + 'DELETE FROM music_play_records WHERE username = ? AND key = ?' + ) .bind(userName, key) .run(); } catch (err) { @@ -490,19 +568,24 @@ export class D1Storage implements IStorage { // ==================== 音乐歌单相关 ==================== - async createMusicPlaylist(userName: string, playlist: { - id: string; - name: string; - description?: string; - cover?: string; - }): Promise { + async createMusicPlaylist( + userName: string, + playlist: { + id: string; + name: string; + description?: string; + cover?: string; + } + ): Promise { try { const now = Date.now(); await this.db - .prepare(` + .prepare( + ` INSERT INTO music_playlists (id, username, name, description, cover, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) - `) + ` + ) .bind( playlist.id, userName, @@ -546,7 +629,9 @@ export class D1Storage implements IStorage { async getUserMusicPlaylists(userName: string): Promise { try { const results = await this.db - .prepare('SELECT * FROM music_playlists WHERE username = ? ORDER BY created_at DESC') + .prepare( + 'SELECT * FROM music_playlists WHERE username = ? ORDER BY created_at DESC' + ) .bind(userName) .all(); @@ -567,11 +652,14 @@ export class D1Storage implements IStorage { } } - async updateMusicPlaylist(playlistId: string, updates: { - name?: string; - description?: string; - cover?: string; - }): Promise { + async updateMusicPlaylist( + playlistId: string, + updates: { + name?: string; + description?: string; + cover?: string; + } + ): Promise { try { const fields: string[] = []; const values: any[] = []; @@ -618,28 +706,34 @@ export class D1Storage implements IStorage { } } - async addSongToPlaylist(playlistId: string, song: { - platform: string; - id: string; - name: string; - artist: string; - album?: string; - pic?: string; - duration: number; - }): Promise { + async addSongToPlaylist( + playlistId: string, + song: { + platform: string; + id: string; + name: string; + artist: string; + album?: string; + pic?: string; + duration: number; + } + ): Promise { try { const now = Date.now(); // 获取当前最大的 sort_order const maxOrderResult = await this.db - .prepare('SELECT MAX(sort_order) as max_order FROM music_playlist_songs WHERE playlist_id = ?') + .prepare( + 'SELECT MAX(sort_order) as max_order FROM music_playlist_songs WHERE playlist_id = ?' + ) .bind(playlistId) .first(); - const nextOrder = (maxOrderResult?.max_order as number || 0) + 1; + const nextOrder = ((maxOrderResult?.max_order as number) || 0) + 1; await this.db - .prepare(` + .prepare( + ` INSERT INTO music_playlist_songs ( playlist_id, platform, song_id, name, artist, album, pic, duration, added_at, sort_order ) @@ -650,7 +744,8 @@ export class D1Storage implements IStorage { album = excluded.album, pic = excluded.pic, duration = excluded.duration - `) + ` + ) .bind( playlistId, song.platform, @@ -667,7 +762,9 @@ export class D1Storage implements IStorage { // 更新歌单的 updated_at 和封面(如果是第一首歌) const songCount = await this.db - .prepare('SELECT COUNT(*) as count FROM music_playlist_songs WHERE playlist_id = ?') + .prepare( + 'SELECT COUNT(*) as count FROM music_playlist_songs WHERE playlist_id = ?' + ) .bind(playlistId) .first(); @@ -685,10 +782,16 @@ export class D1Storage implements IStorage { } } - async removeSongFromPlaylist(playlistId: string, platform: string, songId: string): Promise { + async removeSongFromPlaylist( + playlistId: string, + platform: string, + songId: string + ): Promise { try { await this.db - .prepare('DELETE FROM music_playlist_songs WHERE playlist_id = ? AND platform = ? AND song_id = ?') + .prepare( + 'DELETE FROM music_playlist_songs WHERE playlist_id = ? AND platform = ? AND song_id = ?' + ) .bind(playlistId, platform, songId) .run(); @@ -706,7 +809,9 @@ export class D1Storage implements IStorage { async getPlaylistSongs(playlistId: string): Promise { try { const results = await this.db - .prepare('SELECT * FROM music_playlist_songs WHERE playlist_id = ? ORDER BY sort_order ASC') + .prepare( + 'SELECT * FROM music_playlist_songs WHERE playlist_id = ? ORDER BY sort_order ASC' + ) .bind(playlistId) .all(); @@ -729,10 +834,16 @@ export class D1Storage implements IStorage { } } - async isSongInPlaylist(playlistId: string, platform: string, songId: string): Promise { + async isSongInPlaylist( + playlistId: string, + platform: string, + songId: string + ): Promise { try { const result = await this.db - .prepare('SELECT 1 FROM music_playlist_songs WHERE playlist_id = ? AND platform = ? AND song_id = ? LIMIT 1') + .prepare( + 'SELECT 1 FROM music_playlist_songs WHERE playlist_id = ? AND platform = ? AND song_id = ? LIMIT 1' + ) .bind(playlistId, platform, songId) .first(); @@ -749,7 +860,9 @@ export class D1Storage implements IStorage { try { const results = await this.db // 按队列顺序返回;当前播放项由最大 last_played_at 决定 - .prepare('SELECT * FROM music_v2_history WHERE username = ? ORDER BY created_at ASC, id ASC') + .prepare( + 'SELECT * FROM music_v2_history WHERE username = ? ORDER BY created_at ASC, id ASC' + ) .bind(userName) .all(); @@ -778,10 +891,14 @@ export class D1Storage implements IStorage { } } - async upsertMusicV2History(userName: string, record: MusicV2HistoryRecord): Promise { + async upsertMusicV2History( + userName: string, + record: MusicV2HistoryRecord + ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO music_v2_history ( username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec, play_progress_sec, last_played_at, play_count, last_quality, created_at, updated_at @@ -801,7 +918,8 @@ export class D1Storage implements IStorage { play_count = excluded.play_count, last_quality = excluded.last_quality, updated_at = excluded.updated_at - `) + ` + ) .bind( userName, record.songId, @@ -827,7 +945,10 @@ export class D1Storage implements IStorage { } } - async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise { + async batchUpsertMusicV2History( + userName: string, + records: MusicV2HistoryRecord[] + ): Promise { for (const record of records) { await this.upsertMusicV2History(userName, record); } @@ -835,7 +956,9 @@ export class D1Storage implements IStorage { async deleteMusicV2History(userName: string, songId: string): Promise { await this.db - .prepare('DELETE FROM music_v2_history WHERE username = ? AND song_id = ?') + .prepare( + 'DELETE FROM music_v2_history WHERE username = ? AND song_id = ?' + ) .bind(userName, songId) .run(); } @@ -849,23 +972,39 @@ export class D1Storage implements IStorage { // ==================== Music V2 歌单相关 ==================== - async createMusicV2Playlist(userName: string, playlist: { - id: string; - name: string; - description?: string; - cover?: string; - }): Promise { + async createMusicV2Playlist( + userName: string, + playlist: { + id: string; + name: string; + description?: string; + cover?: string; + } + ): Promise { const now = Date.now(); await this.db - .prepare(` + .prepare( + ` INSERT INTO music_v2_playlists (id, username, name, description, cover, song_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `) - .bind(playlist.id, userName, playlist.name, playlist.description || null, playlist.cover || null, 0, now, now) + ` + ) + .bind( + playlist.id, + userName, + playlist.name, + playlist.description || null, + playlist.cover || null, + 0, + now, + now + ) .run(); } - async getMusicV2Playlist(playlistId: string): Promise { + async getMusicV2Playlist( + playlistId: string + ): Promise { const row: any = await this.db .prepare('SELECT * FROM music_v2_playlists WHERE id = ?') .bind(playlistId) @@ -885,9 +1024,13 @@ export class D1Storage implements IStorage { }; } - async listMusicV2Playlists(userName: string): Promise { + async listMusicV2Playlists( + userName: string + ): Promise { const results = await this.db - .prepare('SELECT * FROM music_v2_playlists WHERE username = ? ORDER BY updated_at DESC') + .prepare( + 'SELECT * FROM music_v2_playlists WHERE username = ? ORDER BY updated_at DESC' + ) .bind(userName) .all(); @@ -905,12 +1048,15 @@ export class D1Storage implements IStorage { })); } - async updateMusicV2Playlist(playlistId: string, updates: { - name?: string; - description?: string; - cover?: string; - song_count?: number; - }): Promise { + async updateMusicV2Playlist( + playlistId: string, + updates: { + name?: string; + description?: string; + cover?: string; + song_count?: number; + } + ): Promise { const fields: string[] = []; const values: any[] = []; @@ -936,7 +1082,9 @@ export class D1Storage implements IStorage { values.push(playlistId); await this.db - .prepare(`UPDATE music_v2_playlists SET ${fields.join(', ')} WHERE id = ?`) + .prepare( + `UPDATE music_v2_playlists SET ${fields.join(', ')} WHERE id = ?` + ) .bind(...values) .run(); } @@ -948,21 +1096,30 @@ export class D1Storage implements IStorage { .run(); } - async addMusicV2PlaylistItem(playlistId: string, item: MusicV2PlaylistItem): Promise { + async addMusicV2PlaylistItem( + playlistId: string, + item: MusicV2PlaylistItem + ): Promise { const playlist = await this.getMusicV2Playlist(playlistId); if (!playlist) { throw new Error('歌单不存在'); } const maxOrder: any = await this.db - .prepare('SELECT MAX(sort_order) as max_order FROM music_v2_playlist_items WHERE playlist_id = ?') + .prepare( + 'SELECT MAX(sort_order) as max_order FROM music_v2_playlist_items WHERE playlist_id = ?' + ) .bind(playlistId) .first(); - const nextOrder = Math.max(item.sortOrder || 0, (maxOrder?.max_order as number || 0) + 1); + const nextOrder = Math.max( + item.sortOrder || 0, + ((maxOrder?.max_order as number) || 0) + 1 + ); const now = Date.now(); await this.db - .prepare(` + .prepare( + ` INSERT INTO music_v2_playlist_items ( playlist_id, username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec, sort_order, added_at, updated_at ) @@ -977,7 +1134,8 @@ export class D1Storage implements IStorage { duration_text = excluded.duration_text, duration_sec = excluded.duration_sec, updated_at = excluded.updated_at - `) + ` + ) .bind( playlistId, playlist.username, @@ -1003,9 +1161,14 @@ export class D1Storage implements IStorage { }); } - async removeMusicV2PlaylistItem(playlistId: string, songId: string): Promise { + async removeMusicV2PlaylistItem( + playlistId: string, + songId: string + ): Promise { await this.db - .prepare('DELETE FROM music_v2_playlist_items WHERE playlist_id = ? AND song_id = ?') + .prepare( + 'DELETE FROM music_v2_playlist_items WHERE playlist_id = ? AND song_id = ?' + ) .bind(playlistId, songId) .run(); @@ -1016,9 +1179,13 @@ export class D1Storage implements IStorage { }); } - async listMusicV2PlaylistItems(playlistId: string): Promise { + async listMusicV2PlaylistItems( + playlistId: string + ): Promise { const results = await this.db - .prepare('SELECT * FROM music_v2_playlist_items WHERE playlist_id = ? ORDER BY sort_order ASC, added_at ASC') + .prepare( + 'SELECT * FROM music_v2_playlist_items WHERE playlist_id = ? ORDER BY sort_order ASC, added_at ASC' + ) .bind(playlistId) .all(); @@ -1041,9 +1208,14 @@ export class D1Storage implements IStorage { })); } - async hasMusicV2PlaylistItem(playlistId: string, songId: string): Promise { + async hasMusicV2PlaylistItem( + playlistId: string, + songId: string + ): Promise { const row = await this.db - .prepare('SELECT 1 FROM music_v2_playlist_items WHERE playlist_id = ? AND song_id = ? LIMIT 1') + .prepare( + 'SELECT 1 FROM music_v2_playlist_items WHERE playlist_id = ? AND song_id = ? LIMIT 1' + ) .bind(playlistId, songId) .first(); return row !== null; @@ -1096,12 +1268,17 @@ export class D1Storage implements IStorage { async verifyUser(userName: string, password: string): Promise { try { // 检查是否是环境变量中的管理员 - if (userName === process.env.USERNAME && password === process.env.PASSWORD) { + if ( + userName === process.env.USERNAME && + password === process.env.PASSWORD + ) { return true; } const user = await this.db - .prepare('SELECT password_hash FROM users WHERE username = ? AND banned = 0') + .prepare( + 'SELECT password_hash FROM users WHERE username = ? AND banned = 0' + ) .bind(userName) .first(); @@ -1196,12 +1373,16 @@ export class D1Storage implements IStorage { banned: user.banned === 1, tags: user.tags ? JSON.parse(user.tags as string) : undefined, oidcSub: user.oidc_sub as string | undefined, - enabledApis: user.enabled_apis ? JSON.parse(user.enabled_apis as string) : undefined, + enabledApis: user.enabled_apis + ? JSON.parse(user.enabled_apis as string) + : undefined, created_at: user.created_at as number, playrecord_migrated: user.playrecord_migrated === 1, favorite_migrated: user.favorite_migrated === 1, skip_migrated: user.skip_migrated === 1, - last_movie_request_time: user.last_movie_request_time as number | undefined, + last_movie_request_time: user.last_movie_request_time as + | number + | undefined, email: user.email as string | undefined, emailNotifications: user.email_notifications === 1, }; @@ -1232,13 +1413,15 @@ export class D1Storage implements IStorage { // 为站长创建数据库记录 try { await this.db - .prepare(` + .prepare( + ` INSERT INTO users ( username, password_hash, role, banned, created_at, playrecord_migrated, favorite_migrated, skip_migrated ) VALUES (?, ?, ?, 0, ?, 1, 1, 1) - `) + ` + ) .bind( userName, '', // 站长不需要密码哈希 @@ -1276,14 +1459,16 @@ export class D1Storage implements IStorage { const passwordHash = await this.hashPassword(password); await this.db - .prepare(` + .prepare( + ` INSERT INTO users ( username, password_hash, role, banned, tags, oidc_sub, enabled_apis, created_at, playrecord_migrated, favorite_migrated, skip_migrated ) VALUES (?, ?, ?, 0, ?, ?, ?, ?, 1, 1, 1) - `) + ` + ) .bind( userName, passwordHash, @@ -1327,7 +1512,9 @@ export class D1Storage implements IStorage { // 获取总数 const countQuery = trimmedSearch ? this.db - .prepare('SELECT COUNT(*) as total FROM users WHERE username LIKE ?') + .prepare( + 'SELECT COUNT(*) as total FROM users WHERE username LIKE ?' + ) .bind(searchPattern) : this.db.prepare('SELECT COUNT(*) as total FROM users'); const countResult = await countQuery.first(); @@ -1370,21 +1557,25 @@ export class D1Storage implements IStorage { // 获取用户列表(按创建时间降序) const listQuery = trimmedSearch ? this.db - .prepare(` + .prepare( + ` SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at FROM users WHERE username LIKE ? ORDER BY created_at DESC LIMIT ? OFFSET ? - `) + ` + ) .bind(searchPattern, actualLimit, actualOffset) : this.db - .prepare(` + .prepare( + ` SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at FROM users ORDER BY created_at DESC LIMIT ? OFFSET ? - `) + ` + ) .bind(actualLimit, actualOffset); const result = await listQuery.all(); @@ -1421,7 +1612,9 @@ export class D1Storage implements IStorage { banned: user.banned === 1, tags: user.tags ? JSON.parse(user.tags as string) : undefined, oidcSub: user.oidc_sub as string | undefined, - enabledApis: user.enabled_apis ? JSON.parse(user.enabled_apis as string) : undefined, + enabledApis: user.enabled_apis + ? JSON.parse(user.enabled_apis as string) + : undefined, created_at: user.created_at as number, }); } @@ -1568,10 +1761,12 @@ export class D1Storage implements IStorage { try { // SQLite 不支持 JSON 查询,需要使用 LIKE const result = await this.db - .prepare(` + .prepare( + ` SELECT username FROM users WHERE tags LIKE ? - `) + ` + ) .bind(`%"${tagName}"%`) .all(); @@ -1600,7 +1795,10 @@ export class D1Storage implements IStorage { } // 直接设置用户密码哈希(用于数据导入,不进行二次哈希) - async setUserPasswordHash(userName: string, passwordHash: string): Promise { + async setUserPasswordHash( + userName: string, + passwordHash: string + ): Promise { try { await this.db .prepare('UPDATE users SET password_hash = ? WHERE username = ?') @@ -1625,14 +1823,16 @@ export class D1Storage implements IStorage { ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO users ( username, password_hash, role, banned, tags, oidc_sub, enabled_apis, created_at, playrecord_migrated, favorite_migrated, skip_migrated ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 1, 1) - `) + ` + ) .bind( userName, passwordHash, @@ -1693,7 +1893,10 @@ export class D1Storage implements IStorage { } } - async setEmailNotificationPreference?(userName: string, enabled: boolean): Promise { + async setEmailNotificationPreference?( + userName: string, + enabled: boolean + ): Promise { try { await this.db .prepare('UPDATE users SET email_notifications = ? WHERE username = ?') @@ -1724,10 +1927,15 @@ export class D1Storage implements IStorage { } } - async setTvboxSubscribeToken?(userName: string, token: string): Promise { + async setTvboxSubscribeToken?( + userName: string, + token: string + ): Promise { try { await this.db - .prepare('UPDATE users SET tvbox_subscribe_token = ? WHERE username = ?') + .prepare( + 'UPDATE users SET tvbox_subscribe_token = ? WHERE username = ?' + ) .bind(token, userName) .run(); @@ -1758,7 +1966,9 @@ export class D1Storage implements IStorage { async getSearchHistory(userName: string): Promise { try { const results = await this.db - .prepare('SELECT keyword FROM search_history WHERE username = ? ORDER BY timestamp DESC LIMIT 20') + .prepare( + 'SELECT keyword FROM search_history WHERE username = ? ORDER BY timestamp DESC LIMIT 20' + ) .bind(userName) .all(); @@ -1776,24 +1986,29 @@ export class D1Storage implements IStorage { // 插入或更新时间戳 await this.db - .prepare(` + .prepare( + ` INSERT INTO search_history (username, keyword, timestamp) VALUES (?, ?, ?) ON CONFLICT(username, keyword) DO UPDATE SET timestamp = excluded.timestamp - `) + ` + ) .bind(userName, keyword, timestamp) .run(); // 保持最多 20 条记录 const countResult = await this.db - .prepare('SELECT COUNT(*) as count FROM search_history WHERE username = ?') + .prepare( + 'SELECT COUNT(*) as count FROM search_history WHERE username = ?' + ) .bind(userName) .first(); const count = (countResult?.count as number) || 0; if (count > 20) { await this.db - .prepare(` + .prepare( + ` DELETE FROM search_history WHERE username = ? AND id NOT IN ( @@ -1802,7 +2017,8 @@ export class D1Storage implements IStorage { ORDER BY timestamp DESC LIMIT 20 ) - `) + ` + ) .bind(userName, userName) .run(); } @@ -1816,7 +2032,9 @@ export class D1Storage implements IStorage { try { if (keyword) { await this.db - .prepare('DELETE FROM search_history WHERE username = ? AND keyword = ?') + .prepare( + 'DELETE FROM search_history WHERE username = ? AND keyword = ?' + ) .bind(userName, keyword) .run(); } else { @@ -1833,7 +2051,10 @@ export class D1Storage implements IStorage { // ==================== 漫画书架 ==================== - async getMangaShelf(userName: string, key: string): Promise { + async getMangaShelf( + userName: string, + key: string + ): Promise { try { await this.schemaReady; const result = await this.db @@ -1857,11 +2078,13 @@ export class D1Storage implements IStorage { latestChapterId: (result.latest_chapter_id as string) || undefined, latestChapterName: (result.latest_chapter_name as string) || undefined, latestChapterCount: - result.latest_chapter_count === null || result.latest_chapter_count === undefined + result.latest_chapter_count === null || + result.latest_chapter_count === undefined ? undefined : Number(result.latest_chapter_count), unreadChapterCount: - result.unread_chapter_count === null || result.unread_chapter_count === undefined + result.unread_chapter_count === null || + result.unread_chapter_count === undefined ? undefined : Number(result.unread_chapter_count), }; @@ -1871,11 +2094,16 @@ export class D1Storage implements IStorage { } } - async setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise { + async setMangaShelf( + userName: string, + key: string, + item: MangaShelfItem + ): Promise { try { await this.schemaReady; await this.db - .prepare(` + .prepare( + ` INSERT INTO manga_shelf ( username, key, source_id, source_name, manga_id, title, cover, save_time, description, author, status, last_chapter_id, last_chapter_name, @@ -1898,7 +2126,8 @@ export class D1Storage implements IStorage { latest_chapter_name = excluded.latest_chapter_name, latest_chapter_count = excluded.latest_chapter_count, unread_chapter_count = excluded.unread_chapter_count - `) + ` + ) .bind( userName, key, @@ -1925,11 +2154,15 @@ export class D1Storage implements IStorage { } } - async getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }> { + async getAllMangaShelf( + userName: string + ): Promise<{ [key: string]: MangaShelfItem }> { try { await this.schemaReady; const results = await this.db - .prepare('SELECT * FROM manga_shelf WHERE username = ? ORDER BY save_time DESC') + .prepare( + 'SELECT * FROM manga_shelf WHERE username = ? ORDER BY save_time DESC' + ) .bind(userName) .all(); @@ -1952,11 +2185,13 @@ export class D1Storage implements IStorage { latestChapterId: (row.latest_chapter_id as string) || undefined, latestChapterName: (row.latest_chapter_name as string) || undefined, latestChapterCount: - row.latest_chapter_count === null || row.latest_chapter_count === undefined + row.latest_chapter_count === null || + row.latest_chapter_count === undefined ? undefined : Number(row.latest_chapter_count), unreadChapterCount: - row.unread_chapter_count === null || row.unread_chapter_count === undefined + row.unread_chapter_count === null || + row.unread_chapter_count === undefined ? undefined : Number(row.unread_chapter_count), }; @@ -1983,10 +2218,15 @@ export class D1Storage implements IStorage { // ==================== 漫画阅读历史 ==================== - async getMangaReadRecord(userName: string, key: string): Promise { + async getMangaReadRecord( + userName: string, + key: string + ): Promise { try { const result = await this.db - .prepare('SELECT * FROM manga_read_records WHERE username = ? AND key = ?') + .prepare( + 'SELECT * FROM manga_read_records WHERE username = ? AND key = ?' + ) .bind(userName, key) .first(); @@ -2009,10 +2249,15 @@ export class D1Storage implements IStorage { } } - async setMangaReadRecord(userName: string, key: string, record: MangaReadRecord): Promise { + async setMangaReadRecord( + userName: string, + key: string, + record: MangaReadRecord + ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO manga_read_records ( username, key, source_id, source_name, manga_id, title, cover, chapter_id, chapter_name, page_index, page_count, save_time @@ -2029,7 +2274,8 @@ export class D1Storage implements IStorage { page_index = excluded.page_index, page_count = excluded.page_count, save_time = excluded.save_time - `) + ` + ) .bind( userName, key, @@ -2051,10 +2297,14 @@ export class D1Storage implements IStorage { } } - async getAllMangaReadRecords(userName: string): Promise<{ [key: string]: MangaReadRecord }> { + async getAllMangaReadRecords( + userName: string + ): Promise<{ [key: string]: MangaReadRecord }> { try { const results = await this.db - .prepare('SELECT * FROM manga_read_records WHERE username = ? ORDER BY save_time DESC') + .prepare( + 'SELECT * FROM manga_read_records WHERE username = ? ORDER BY save_time DESC' + ) .bind(userName) .all(); @@ -2086,7 +2336,9 @@ export class D1Storage implements IStorage { async deleteMangaReadRecord(userName: string, key: string): Promise { try { await this.db - .prepare('DELETE FROM manga_read_records WHERE username = ? AND key = ?') + .prepare( + 'DELETE FROM manga_read_records WHERE username = ? AND key = ?' + ) .bind(userName, key) .run(); } catch (err) { @@ -2097,10 +2349,15 @@ export class D1Storage implements IStorage { async cleanupOldMangaReadRecords(userName: string): Promise { try { - const maxRecords = parseInt(process.env.MAX_MANGA_HISTORY_PER_USER || '100', 10); + const maxRecords = parseInt( + process.env.MAX_MANGA_HISTORY_PER_USER || '100', + 10 + ); const threshold = maxRecords + 10; const countResult = await this.db - .prepare('SELECT COUNT(*) as count FROM manga_read_records WHERE username = ?') + .prepare( + 'SELECT COUNT(*) as count FROM manga_read_records WHERE username = ?' + ) .bind(userName) .first(); @@ -2108,7 +2365,8 @@ export class D1Storage implements IStorage { if (count <= threshold) return; await this.db - .prepare(` + .prepare( + ` DELETE FROM manga_read_records WHERE username = ? AND key NOT IN ( @@ -2117,7 +2375,8 @@ export class D1Storage implements IStorage { ORDER BY save_time DESC LIMIT ? ) - `) + ` + ) .bind(userName, userName, maxRecords) .run(); } catch (err) { @@ -2126,12 +2385,17 @@ export class D1Storage implements IStorage { } } - // ==================== 电子书书架 ==================== - async getBookShelf(userName: string, key: string): Promise { + async getBookShelf( + userName: string, + key: string + ): Promise { try { - const result = await this.db.prepare('SELECT * FROM book_shelf WHERE username = ? AND key = ?').bind(userName, key).first(); + const result = await this.db + .prepare('SELECT * FROM book_shelf WHERE username = ? AND key = ?') + .bind(userName, key) + .first(); if (!result) return null; return { sourceId: result.source_id as string, @@ -2143,9 +2407,18 @@ export class D1Storage implements IStorage { format: (result.format as 'epub' | 'pdf' | null) || undefined, detailHref: (result.detail_href as string) || undefined, acquisitionHref: (result.acquisition_href as string) || undefined, - progressPercent: result.progress_percent === null || result.progress_percent === undefined ? undefined : Number(result.progress_percent), - lastReadTime: result.last_read_time === null || result.last_read_time === undefined ? undefined : Number(result.last_read_time), - lastLocatorType: (result.last_locator_type as BookShelfItem['lastLocatorType']) || undefined, + progressPercent: + result.progress_percent === null || + result.progress_percent === undefined + ? undefined + : Number(result.progress_percent), + lastReadTime: + result.last_read_time === null || result.last_read_time === undefined + ? undefined + : Number(result.last_read_time), + lastLocatorType: + (result.last_locator_type as BookShelfItem['lastLocatorType']) || + undefined, lastLocatorValue: (result.last_locator_value as string) || undefined, lastChapterTitle: (result.last_chapter_title as string) || undefined, saveTime: Number(result.save_time || 0), @@ -2156,9 +2429,15 @@ export class D1Storage implements IStorage { } } - async setBookShelf(userName: string, key: string, item: BookShelfItem): Promise { + async setBookShelf( + userName: string, + key: string, + item: BookShelfItem + ): Promise { try { - await this.db.prepare(` + await this.db + .prepare( + ` INSERT INTO book_shelf ( username, key, source_id, source_name, book_id, title, author, cover, format, detail_href, acquisition_href, progress_percent, last_read_time, last_locator_type, last_locator_value, last_chapter_title, save_time @@ -2180,21 +2459,44 @@ export class D1Storage implements IStorage { last_locator_value = excluded.last_locator_value, last_chapter_title = excluded.last_chapter_title, save_time = excluded.save_time - `).bind( - userName, key, item.sourceId, item.sourceName, item.bookId, item.title, item.author || null, - item.cover || null, item.format || null, item.detailHref || null, item.acquisitionHref || null, item.progressPercent ?? null, - item.lastReadTime ?? null, item.lastLocatorType || null, item.lastLocatorValue || null, - item.lastChapterTitle || null, item.saveTime - ).run(); + ` + ) + .bind( + userName, + key, + item.sourceId, + item.sourceName, + item.bookId, + item.title, + item.author || null, + item.cover || null, + item.format || null, + item.detailHref || null, + item.acquisitionHref || null, + item.progressPercent ?? null, + item.lastReadTime ?? null, + item.lastLocatorType || null, + item.lastLocatorValue || null, + item.lastChapterTitle || null, + item.saveTime + ) + .run(); } catch (err) { console.error('D1Storage.setBookShelf error:', err); throw err; } } - async getAllBookShelf(userName: string): Promise<{ [key: string]: BookShelfItem }> { + async getAllBookShelf( + userName: string + ): Promise<{ [key: string]: BookShelfItem }> { try { - const results = await this.db.prepare('SELECT * FROM book_shelf WHERE username = ? ORDER BY COALESCE(last_read_time, save_time) DESC').bind(userName).all(); + const results = await this.db + .prepare( + 'SELECT * FROM book_shelf WHERE username = ? ORDER BY COALESCE(last_read_time, save_time) DESC' + ) + .bind(userName) + .all(); const shelves: { [key: string]: BookShelfItem } = {}; if (!results.results) return shelves; for (const row of results.results) { @@ -2208,9 +2510,17 @@ export class D1Storage implements IStorage { format: (row.format as 'epub' | 'pdf' | null) || undefined, detailHref: (row.detail_href as string) || undefined, acquisitionHref: (row.acquisition_href as string) || undefined, - progressPercent: row.progress_percent === null || row.progress_percent === undefined ? undefined : Number(row.progress_percent), - lastReadTime: row.last_read_time === null || row.last_read_time === undefined ? undefined : Number(row.last_read_time), - lastLocatorType: (row.last_locator_type as BookShelfItem['lastLocatorType']) || undefined, + progressPercent: + row.progress_percent === null || row.progress_percent === undefined + ? undefined + : Number(row.progress_percent), + lastReadTime: + row.last_read_time === null || row.last_read_time === undefined + ? undefined + : Number(row.last_read_time), + lastLocatorType: + (row.last_locator_type as BookShelfItem['lastLocatorType']) || + undefined, lastLocatorValue: (row.last_locator_value as string) || undefined, lastChapterTitle: (row.last_chapter_title as string) || undefined, saveTime: Number(row.save_time || 0), @@ -2225,7 +2535,10 @@ export class D1Storage implements IStorage { async deleteBookShelf(userName: string, key: string): Promise { try { - await this.db.prepare('DELETE FROM book_shelf WHERE username = ? AND key = ?').bind(userName, key).run(); + await this.db + .prepare('DELETE FROM book_shelf WHERE username = ? AND key = ?') + .bind(userName, key) + .run(); } catch (err) { console.error('D1Storage.deleteBookShelf error:', err); throw err; @@ -2234,9 +2547,17 @@ export class D1Storage implements IStorage { // ==================== 电子书阅读历史 ==================== - async getBookReadRecord(userName: string, key: string): Promise { + async getBookReadRecord( + userName: string, + key: string + ): Promise { try { - const result = await this.db.prepare('SELECT * FROM book_read_records WHERE username = ? AND key = ?').bind(userName, key).first(); + const result = await this.db + .prepare( + 'SELECT * FROM book_read_records WHERE username = ? AND key = ?' + ) + .bind(userName, key) + .first(); if (!result) return null; return { sourceId: result.source_id as string, @@ -2265,9 +2586,15 @@ export class D1Storage implements IStorage { } } - async setBookReadRecord(userName: string, key: string, record: BookReadRecord): Promise { + async setBookReadRecord( + userName: string, + key: string, + record: BookReadRecord + ): Promise { try { - await this.db.prepare(` + await this.db + .prepare( + ` INSERT INTO book_read_records ( username, key, source_id, source_name, book_id, title, author, cover, format, detail_href, acquisition_href, locator_type, locator_value, chapter_title, chapter_href, progress_percent, save_time @@ -2289,21 +2616,44 @@ export class D1Storage implements IStorage { chapter_href = excluded.chapter_href, progress_percent = excluded.progress_percent, save_time = excluded.save_time - `).bind( - userName, key, record.sourceId, record.sourceName, record.bookId, record.title, record.author || null, - record.cover || null, record.format, record.detailHref || null, record.acquisitionHref || null, record.locator.type, record.locator.value, - record.chapterTitle || record.locator.chapterTitle || null, record.chapterHref || record.locator.href || null, - record.progressPercent, record.saveTime - ).run(); + ` + ) + .bind( + userName, + key, + record.sourceId, + record.sourceName, + record.bookId, + record.title, + record.author || null, + record.cover || null, + record.format, + record.detailHref || null, + record.acquisitionHref || null, + record.locator.type, + record.locator.value, + record.chapterTitle || record.locator.chapterTitle || null, + record.chapterHref || record.locator.href || null, + record.progressPercent, + record.saveTime + ) + .run(); } catch (err) { console.error('D1Storage.setBookReadRecord error:', err); throw err; } } - async getAllBookReadRecords(userName: string): Promise<{ [key: string]: BookReadRecord }> { + async getAllBookReadRecords( + userName: string + ): Promise<{ [key: string]: BookReadRecord }> { try { - const results = await this.db.prepare('SELECT * FROM book_read_records WHERE username = ? ORDER BY save_time DESC').bind(userName).all(); + const results = await this.db + .prepare( + 'SELECT * FROM book_read_records WHERE username = ? ORDER BY save_time DESC' + ) + .bind(userName) + .all(); const records: { [key: string]: BookReadRecord } = {}; if (!results.results) return records; for (const row of results.results) { @@ -2338,7 +2688,10 @@ export class D1Storage implements IStorage { async deleteBookReadRecord(userName: string, key: string): Promise { try { - await this.db.prepare('DELETE FROM book_read_records WHERE username = ? AND key = ?').bind(userName, key).run(); + await this.db + .prepare('DELETE FROM book_read_records WHERE username = ? AND key = ?') + .bind(userName, key) + .run(); } catch (err) { console.error('D1Storage.deleteBookReadRecord error:', err); throw err; @@ -2347,12 +2700,22 @@ export class D1Storage implements IStorage { async cleanupOldBookReadRecords(userName: string): Promise { try { - const maxRecords = parseInt(process.env.MAX_BOOK_HISTORY_PER_USER || '100', 10); + const maxRecords = parseInt( + process.env.MAX_BOOK_HISTORY_PER_USER || '100', + 10 + ); const threshold = maxRecords + 10; - const countResult = await this.db.prepare('SELECT COUNT(*) as count FROM book_read_records WHERE username = ?').bind(userName).first(); + const countResult = await this.db + .prepare( + 'SELECT COUNT(*) as count FROM book_read_records WHERE username = ?' + ) + .bind(userName) + .first(); const count = Number(countResult?.count || 0); if (count <= threshold) return; - await this.db.prepare(` + await this.db + .prepare( + ` DELETE FROM book_read_records WHERE username = ? AND key NOT IN ( @@ -2361,7 +2724,10 @@ export class D1Storage implements IStorage { ORDER BY save_time DESC LIMIT ? ) - `).bind(userName, userName, maxRecords).run(); + ` + ) + .bind(userName, userName, maxRecords) + .run(); } catch (err) { console.error('D1Storage.cleanupOldBookReadRecords error:', err); throw err; @@ -2370,7 +2736,11 @@ export class D1Storage implements IStorage { // ==================== 跳过配置 ==================== - async getSkipConfig(userName: string, source: string, id: string): Promise { + async getSkipConfig( + userName: string, + source: string, + id: string + ): Promise { try { const key = `${source}+${id}`; const result = await this.db @@ -2390,19 +2760,32 @@ export class D1Storage implements IStorage { } } - async setSkipConfig(userName: string, source: string, id: string, config: SkipConfig): Promise { + async setSkipConfig( + userName: string, + source: string, + id: string, + config: SkipConfig + ): Promise { try { const key = `${source}+${id}`; await this.db - .prepare(` + .prepare( + ` INSERT INTO skip_configs (username, key, enable, intro_time, outro_time) VALUES (?, ?, ?, ?, ?) ON CONFLICT(username, key) DO UPDATE SET enable = excluded.enable, intro_time = excluded.intro_time, outro_time = excluded.outro_time - `) - .bind(userName, key, config.enable ? 1 : 0, config.intro_time, config.outro_time) + ` + ) + .bind( + userName, + key, + config.enable ? 1 : 0, + config.intro_time, + config.outro_time + ) .run(); } catch (err) { console.error('D1Storage.setSkipConfig error:', err); @@ -2410,7 +2793,11 @@ export class D1Storage implements IStorage { } } - async deleteSkipConfig(userName: string, source: string, id: string): Promise { + async deleteSkipConfig( + userName: string, + source: string, + id: string + ): Promise { try { const key = `${source}+${id}`; await this.db @@ -2423,7 +2810,9 @@ export class D1Storage implements IStorage { } } - async getAllSkipConfigs(userName: string): Promise<{ [key: string]: SkipConfig }> { + async getAllSkipConfigs( + userName: string + ): Promise<{ [key: string]: SkipConfig }> { try { const results = await this.db .prepare('SELECT * FROM skip_configs WHERE username = ?') @@ -2463,7 +2852,9 @@ export class D1Storage implements IStorage { // ==================== 弹幕过滤配置 ==================== - async getDanmakuFilterConfig(userName: string): Promise { + async getDanmakuFilterConfig( + userName: string + ): Promise { try { const result = await this.db .prepare('SELECT rules FROM danmaku_filter_configs WHERE username = ?') @@ -2478,14 +2869,19 @@ export class D1Storage implements IStorage { } } - async setDanmakuFilterConfig(userName: string, config: DanmakuFilterConfig): Promise { + async setDanmakuFilterConfig( + userName: string, + config: DanmakuFilterConfig + ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO danmaku_filter_configs (username, rules) VALUES (?, ?) ON CONFLICT(username) DO UPDATE SET rules = excluded.rules - `) + ` + ) .bind(userName, JSON.stringify(config)) .run(); } catch (err) { @@ -2511,7 +2907,9 @@ export class D1Storage implements IStorage { async getNotifications(userName: string): Promise { try { const results = await this.db - .prepare('SELECT * FROM notifications WHERE username = ? ORDER BY timestamp DESC') + .prepare( + 'SELECT * FROM notifications WHERE username = ? ORDER BY timestamp DESC' + ) .bind(userName) .all(); @@ -2531,13 +2929,18 @@ export class D1Storage implements IStorage { } } - async addNotification(userName: string, notification: Notification): Promise { + async addNotification( + userName: string, + notification: Notification + ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO notifications (id, username, type, title, message, timestamp, read, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `) + ` + ) .bind( notification.id, userName, @@ -2555,10 +2958,15 @@ export class D1Storage implements IStorage { } } - async markNotificationAsRead(userName: string, notificationId: string): Promise { + async markNotificationAsRead( + userName: string, + notificationId: string + ): Promise { try { await this.db - .prepare('UPDATE notifications SET read = 1 WHERE username = ? AND id = ?') + .prepare( + 'UPDATE notifications SET read = 1 WHERE username = ? AND id = ?' + ) .bind(userName, notificationId) .run(); } catch (err) { @@ -2567,7 +2975,10 @@ export class D1Storage implements IStorage { } } - async deleteNotification(userName: string, notificationId: string): Promise { + async deleteNotification( + userName: string, + notificationId: string + ): Promise { try { await this.db .prepare('DELETE FROM notifications WHERE username = ? AND id = ?') @@ -2594,7 +3005,9 @@ export class D1Storage implements IStorage { async getUnreadNotificationCount(userName: string): Promise { try { const result = await this.db - .prepare('SELECT COUNT(*) as count FROM notifications WHERE username = ? AND read = 0') + .prepare( + 'SELECT COUNT(*) as count FROM notifications WHERE username = ? AND read = 0' + ) .bind(userName) .first(); @@ -2639,14 +3052,16 @@ export class D1Storage implements IStorage { async createMovieRequest(request: MovieRequest): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO movie_requests ( id, tmdb_id, title, year, media_type, season, poster, overview, requested_by, request_count, status, created_at, updated_at, fulfilled_at, fulfilled_source, fulfilled_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `) + ` + ) .bind( request.id, request.tmdbId || null, @@ -2672,7 +3087,10 @@ export class D1Storage implements IStorage { } } - async updateMovieRequest(requestId: string, updates: Partial): Promise { + async updateMovieRequest( + requestId: string, + updates: Partial + ): Promise { try { const fields: string[] = []; const values: any[] = []; @@ -2732,7 +3150,9 @@ export class D1Storage implements IStorage { async getUserMovieRequests(userName: string): Promise { try { const results = await this.db - .prepare('SELECT request_id FROM user_movie_requests WHERE username = ?') + .prepare( + 'SELECT request_id FROM user_movie_requests WHERE username = ?' + ) .bind(userName) .all(); @@ -2744,10 +3164,15 @@ export class D1Storage implements IStorage { } } - async addUserMovieRequest(userName: string, requestId: string): Promise { + async addUserMovieRequest( + userName: string, + requestId: string + ): Promise { try { await this.db - .prepare('INSERT OR IGNORE INTO user_movie_requests (username, request_id) VALUES (?, ?)') + .prepare( + 'INSERT OR IGNORE INTO user_movie_requests (username, request_id) VALUES (?, ?)' + ) .bind(userName, requestId) .run(); } catch (err) { @@ -2756,10 +3181,15 @@ export class D1Storage implements IStorage { } } - async removeUserMovieRequest(userName: string, requestId: string): Promise { + async removeUserMovieRequest( + userName: string, + requestId: string + ): Promise { try { await this.db - .prepare('DELETE FROM user_movie_requests WHERE username = ? AND request_id = ?') + .prepare( + 'DELETE FROM user_movie_requests WHERE username = ? AND request_id = ?' + ) .bind(userName, requestId) .run(); } catch (err) { @@ -2808,11 +3238,13 @@ export class D1Storage implements IStorage { async setAdminConfig(config: AdminConfig): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO admin_config (id, config, updated_at) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET config = excluded.config, updated_at = excluded.updated_at - `) + ` + ) .bind(JSON.stringify(config), Date.now()) .run(); } catch (err) { @@ -2849,7 +3281,10 @@ export class D1Storage implements IStorage { await this.db.prepare(`DELETE FROM ${table}`).run(); } catch (err) { const message = err instanceof Error ? err.message : String(err); - if (message.includes('no such table') || message.includes('does not exist')) { + if ( + message.includes('no such table') || + message.includes('does not exist') + ) { console.warn('D1Storage.clearAllData warning:', table, message); continue; } @@ -2879,11 +3314,13 @@ export class D1Storage implements IStorage { async setGlobalValue(key: string, value: string): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO global_config (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at - `) + ` + ) .bind(key, value, Date.now()) .run(); } catch (err) { @@ -2907,7 +3344,9 @@ export class D1Storage implements IStorage { async getLastFavoriteCheckTime(userName: string): Promise { try { const result = await this.db - .prepare('SELECT last_check_time FROM favorite_check_times WHERE username = ?') + .prepare( + 'SELECT last_check_time FROM favorite_check_times WHERE username = ?' + ) .bind(userName) .first(); @@ -2918,14 +3357,19 @@ export class D1Storage implements IStorage { } } - async setLastFavoriteCheckTime(userName: string, timestamp: number): Promise { + async setLastFavoriteCheckTime( + userName: string, + timestamp: number + ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO favorite_check_times (username, last_check_time) VALUES (?, ?) ON CONFLICT(username) DO UPDATE SET last_check_time = excluded.last_check_time - `) + ` + ) .bind(userName, timestamp) .run(); } catch (err) { @@ -2934,10 +3378,15 @@ export class D1Storage implements IStorage { } } - async updateLastMovieRequestTime(userName: string, timestamp: number): Promise { + async updateLastMovieRequestTime( + userName: string, + timestamp: number + ): Promise { try { await this.db - .prepare('UPDATE users SET last_movie_request_time = ? WHERE username = ?') + .prepare( + 'UPDATE users SET last_movie_request_time = ? WHERE username = ?' + ) .bind(timestamp, userName) .run(); } catch (err) { @@ -2965,11 +3414,13 @@ class RedisHashAdapter { async hSet(hashKey: string, field: string, value: string): Promise { const key = `${hashKey}:${field}`; await this.db - .prepare(` + .prepare( + ` INSERT INTO global_config (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at - `) + ` + ) .bind(key, value, Date.now()) .run(); } diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts index 0e0ccca..caab615 100644 --- a/src/lib/db.client.ts +++ b/src/lib/db.client.ts @@ -17,7 +17,7 @@ import { getAuthInfoFromBrowserCookie, clearAuthCookie } from './auth'; import { normalizeEpisodeFilterConfig } from './episode-filter'; import { MangaReadRecord, MangaShelfItem } from './manga.types'; -import { DanmakuFilterConfig, EpisodeFilterConfig,SkipConfig } from './types'; +import { DanmakuFilterConfig, EpisodeFilterConfig, SkipConfig } from './types'; // 全局错误触发函数 function triggerGlobalError(message: string) { @@ -97,7 +97,8 @@ const FAVORITES_KEY = 'moontv_favorites'; const MANGA_SHELF_KEY = 'moontv_manga_shelf'; const MANGA_HISTORY_KEY = 'moontv_manga_history'; const DEFAULT_MAX_MANGA_HISTORY_RECORDS = 100; -const DEFAULT_MAX_MANGA_HISTORY_THRESHOLD = DEFAULT_MAX_MANGA_HISTORY_RECORDS + 10; +const DEFAULT_MAX_MANGA_HISTORY_THRESHOLD = + DEFAULT_MAX_MANGA_HISTORY_RECORDS + 10; const SEARCH_HISTORY_KEY = 'moontv_search_history'; const MUSIC_PLAY_RECORDS_KEY = 'moontv_music_play_records'; @@ -141,10 +142,7 @@ class HybridCacheManager { /** * 获取或创建请求 Promise(防止并发重复请求) */ - getOrCreateRequest( - key: string, - fetcher: () => Promise - ): Promise { + getOrCreateRequest(key: string, fetcher: () => Promise): Promise { // 如果已有正在进行的请求,直接返回 if (this.pendingRequests.has(key)) { console.log(`[${key}] 复用进行中的请求`); @@ -153,11 +151,10 @@ class HybridCacheManager { console.log(`[${key}] 创建新请求`); // 创建新请求 - const promise = fetcher() - .finally(() => { - // 请求完成后清除缓存 - this.pendingRequests.delete(key); - }); + const promise = fetcher().finally(() => { + // 请求完成后清除缓存 + this.pendingRequests.delete(key); + }); this.pendingRequests.set(key, promise); return promise; @@ -249,7 +246,10 @@ class HybridCacheManager { delete cache.mangaShelf; } - if (cache.mangaReadRecords && now - cache.mangaReadRecords.timestamp > maxAge) { + if ( + cache.mangaReadRecords && + now - cache.mangaReadRecords.timestamp > maxAge + ) { delete cache.mangaReadRecords; } } @@ -565,7 +565,12 @@ const cacheManager = HybridCacheManager.getInstance(); * 立即从数据库刷新对应类型的缓存以保持数据一致性 */ async function handleDatabaseOperationFailure( - dataType: 'playRecords' | 'favorites' | 'searchHistory' | 'mangaShelf' | 'mangaHistory', + dataType: + | 'playRecords' + | 'favorites' + | 'searchHistory' + | 'mangaShelf' + | 'mangaHistory', error: any ): Promise { console.error(`数据库操作失败 (${dataType}):`, error); @@ -598,12 +603,16 @@ async function handleDatabaseOperationFailure( eventName = 'searchHistoryUpdated'; break; case 'mangaShelf': - freshData = await fetchFromApi>(`/api/manga/shelf`); + freshData = await fetchFromApi>( + `/api/manga/shelf` + ); cacheManager.cacheMangaShelf(freshData); eventName = 'mangaShelfUpdated'; break; case 'mangaHistory': - freshData = await fetchFromApi>(`/api/manga/history`); + freshData = await fetchFromApi>( + `/api/manga/history` + ); cacheManager.cacheMangaReadRecords(freshData); eventName = 'mangaHistoryUpdated'; break; @@ -642,9 +651,16 @@ export async function fetchWithAuth( const text = await res.clone().text(); // 只有当响应体包含 "Unauthorized" 或 "Refresh token expired" 或 "Access token expired" 时才处理 - if (text.includes('Unauthorized') || text.includes('Refresh token expired') || text.includes('Access token expired')) { + if ( + text.includes('Unauthorized') || + text.includes('Refresh token expired') || + text.includes('Access token expired') + ) { // 如果在登录页面,跳过刷新逻辑 - if (typeof window !== 'undefined' && window.location.pathname === '/login') { + if ( + typeof window !== 'undefined' && + window.location.pathname === '/login' + ) { console.log('[fetchWithAuth] On login page, skipping refresh logic'); return res; } @@ -671,7 +687,9 @@ export async function fetchWithAuth( } } else { // 不是认证错误的401,直接返回 - console.log('[fetchWithAuth] Received 401 but not an auth error, skipping refresh'); + console.log( + '[fetchWithAuth] Received 401 but not an auth error, skipping refresh' + ); return res; } @@ -679,9 +697,16 @@ export async function fetchWithAuth( if (res.status === 401) { const text2 = await res.clone().text(); // 再次检查响应体 - if (text2.includes('Unauthorized') || text2.includes('Refresh token expired') || text2.includes('Access token expired')) { + if ( + text2.includes('Unauthorized') || + text2.includes('Refresh token expired') || + text2.includes('Access token expired') + ) { // 检查当前页面是否已经是登录页,避免重复跳转 - if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { + if ( + typeof window !== 'undefined' && + !window.location.pathname.startsWith('/login') + ) { // 调用 logout 接口 try { await fetch('/api/logout', { @@ -824,7 +849,10 @@ export function getCachedPlayRecordsSnapshot(): Record { } } -export function getCachedMangaReadRecordsSnapshot(): Record { +export function getCachedMangaReadRecordsSnapshot(): Record< + string, + MangaReadRecord +> { if (typeof window === 'undefined') { return {}; } @@ -995,6 +1023,66 @@ export async function deletePlayRecord( } } +/** + * 批量删除播放记录。 + * 数据库存储模式下只发起一次 API 请求,并进行一次缓存/事件更新。 + */ +export async function deletePlayRecords(keys: string[]): Promise { + const uniqueKeys = Array.from(new Set(keys)).filter(Boolean); + if (uniqueKeys.length === 0) return; + + // 数据库存储模式:一次性乐观更新 + 一次 API 请求 + if (STORAGE_TYPE !== 'localstorage') { + const cachedRecords = cacheManager.getCachedPlayRecords() || {}; + uniqueKeys.forEach((key) => { + delete cachedRecords[key]; + }); + cacheManager.cachePlayRecords(cachedRecords); + + window.dispatchEvent( + new CustomEvent('playRecordsUpdated', { + detail: cachedRecords, + }) + ); + + try { + await fetchWithAuth('/api/playrecords', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ keys: uniqueKeys }), + }); + } catch (err) { + await handleDatabaseOperationFailure('playRecords', err); + triggerGlobalError('删除播放记录失败'); + throw err; + } + return; + } + + // localstorage 模式:一次性更新本地数据和事件 + if (typeof window === 'undefined') { + console.warn('无法在服务端删除播放记录到 localStorage'); + return; + } + + try { + const allRecords = await getAllPlayRecords(); + uniqueKeys.forEach((key) => { + delete allRecords[key]; + }); + localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords)); + window.dispatchEvent( + new CustomEvent('playRecordsUpdated', { + detail: allRecords, + }) + ); + } catch (err) { + console.error('批量删除播放记录失败:', err); + triggerGlobalError('删除播放记录失败'); + throw err; + } +} + /** * 迁移播放记录到新的 source/id。 * 用于换源时保留单一记忆点语义:当前进度迁移到新源后,再清理旧源记录。 @@ -1038,9 +1126,12 @@ export async function migratePlayRecord( body: JSON.stringify({ key: toKey, record }), }); - await fetchWithAuth(`/api/playrecords?key=${encodeURIComponent(fromKey)}`, { - method: 'DELETE', - }); + await fetchWithAuth( + `/api/playrecords?key=${encodeURIComponent(fromKey)}`, + { + method: 'DELETE', + } + ); }; persistMove().catch((err) => { @@ -1307,7 +1398,8 @@ export async function deleteSearchHistory(keyword: string): Promise { // 模块级别的防重复请求机制 let pendingFavoritesBackgroundRequest: Promise | null = null; -let pendingFavoritesFetchRequest: Promise> | null = null; +let pendingFavoritesFetchRequest: Promise> | null = + null; let lastFavoritesBackgroundFetchTime = 0; const MIN_BACKGROUND_FETCH_INTERVAL = 3000; // 3秒内不重复后台请求 @@ -1329,12 +1421,18 @@ export async function getAllFavorites(): Promise> { if (cachedData) { // 有缓存:返回缓存,后台异步刷新(带防抖和防重复) const now = Date.now(); - if (now - lastFavoritesBackgroundFetchTime > MIN_BACKGROUND_FETCH_INTERVAL && !pendingFavoritesBackgroundRequest) { + if ( + now - lastFavoritesBackgroundFetchTime > + MIN_BACKGROUND_FETCH_INTERVAL && + !pendingFavoritesBackgroundRequest + ) { lastFavoritesBackgroundFetchTime = now; pendingFavoritesBackgroundRequest = (async () => { try { - const freshData = await fetchFromApi>(`/api/favorites`); + const freshData = await fetchFromApi>( + `/api/favorites` + ); // 只有数据真正不同时才更新缓存 if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) { cacheManager.cacheFavorites(freshData); @@ -1363,7 +1461,9 @@ export async function getAllFavorites(): Promise> { pendingFavoritesFetchRequest = (async () => { try { - const freshData = await fetchFromApi>(`/api/favorites`); + const freshData = await fetchFromApi>( + `/api/favorites` + ); cacheManager.cacheFavorites(freshData); return freshData; } catch (err) { @@ -1627,10 +1727,11 @@ export async function clearAllFavorites(): Promise { ); } - // ---------------- 漫画书架 / 历史 API ---------------- -export async function getAllMangaShelf(): Promise> { +export async function getAllMangaShelf(): Promise< + Record +> { if (typeof window === 'undefined') return {}; if (STORAGE_TYPE !== 'localstorage') { @@ -1640,7 +1741,9 @@ export async function getAllMangaShelf(): Promise .then((freshData) => { if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) { cacheManager.cacheMangaShelf(freshData); - window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: freshData })); + window.dispatchEvent( + new CustomEvent('mangaShelfUpdated', { detail: freshData }) + ); } }) .catch((err) => { @@ -1650,7 +1753,9 @@ export async function getAllMangaShelf(): Promise } try { - const freshData = await fetchFromApi>('/api/manga/shelf'); + const freshData = await fetchFromApi>( + '/api/manga/shelf' + ); cacheManager.cacheMangaShelf(freshData); return freshData; } catch (err) { @@ -1671,14 +1776,20 @@ export async function getAllMangaShelf(): Promise } } -export async function saveMangaShelf(sourceId: string, mangaId: string, item: MangaShelfItem): Promise { +export async function saveMangaShelf( + sourceId: string, + mangaId: string, + item: MangaShelfItem +): Promise { const key = generateStorageKey(sourceId, mangaId); if (STORAGE_TYPE !== 'localstorage') { const cached = cacheManager.getCachedMangaShelf() || {}; cached[key] = item; cacheManager.cacheMangaShelf(cached); - window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: cached })); + window.dispatchEvent( + new CustomEvent('mangaShelfUpdated', { detail: cached }) + ); try { await fetchWithAuth('/api/manga/shelf', { @@ -1696,20 +1807,29 @@ export async function saveMangaShelf(sourceId: string, mangaId: string, item: Ma const allItems = await getAllMangaShelf(); allItems[key] = item; localStorage.setItem(MANGA_SHELF_KEY, JSON.stringify(allItems)); - window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: allItems })); + window.dispatchEvent( + new CustomEvent('mangaShelfUpdated', { detail: allItems }) + ); } -export async function deleteMangaShelf(sourceId: string, mangaId: string): Promise { +export async function deleteMangaShelf( + sourceId: string, + mangaId: string +): Promise { const key = generateStorageKey(sourceId, mangaId); if (STORAGE_TYPE !== 'localstorage') { const cached = cacheManager.getCachedMangaShelf() || {}; delete cached[key]; cacheManager.cacheMangaShelf(cached); - window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: cached })); + window.dispatchEvent( + new CustomEvent('mangaShelfUpdated', { detail: cached }) + ); try { - await fetchWithAuth(`/api/manga/shelf?key=${encodeURIComponent(key)}`, { method: 'DELETE' }); + await fetchWithAuth(`/api/manga/shelf?key=${encodeURIComponent(key)}`, { + method: 'DELETE', + }); } catch (err) { await handleDatabaseOperationFailure('mangaShelf', err); throw err; @@ -1720,7 +1840,9 @@ export async function deleteMangaShelf(sourceId: string, mangaId: string): Promi const allItems = await getAllMangaShelf(); delete allItems[key]; localStorage.setItem(MANGA_SHELF_KEY, JSON.stringify(allItems)); - window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: allItems })); + window.dispatchEvent( + new CustomEvent('mangaShelfUpdated', { detail: allItems }) + ); } export async function clearAllMangaShelf(): Promise { @@ -1740,7 +1862,9 @@ export async function clearAllMangaShelf(): Promise { window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: {} })); } -function trimMangaReadRecords(records: Record): Record { +function trimMangaReadRecords( + records: Record +): Record { const entries = Object.entries(records); if (entries.length <= DEFAULT_MAX_MANGA_HISTORY_THRESHOLD) return records; @@ -1751,7 +1875,9 @@ function trimMangaReadRecords(records: Record): Record< ); } -export async function getAllMangaReadRecords(): Promise> { +export async function getAllMangaReadRecords(): Promise< + Record +> { if (typeof window === 'undefined') return {}; if (STORAGE_TYPE !== 'localstorage') { @@ -1761,7 +1887,9 @@ export async function getAllMangaReadRecords(): Promise { if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) { cacheManager.cacheMangaReadRecords(freshData); - window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: freshData })); + window.dispatchEvent( + new CustomEvent('mangaHistoryUpdated', { detail: freshData }) + ); } }) .catch((err) => { @@ -1771,7 +1899,9 @@ export async function getAllMangaReadRecords(): Promise>('/api/manga/history'); + const freshData = await fetchFromApi>( + '/api/manga/history' + ); cacheManager.cacheMangaReadRecords(freshData); return freshData; } catch (err) { @@ -1792,7 +1922,11 @@ export async function getAllMangaReadRecords(): Promise { +export async function saveMangaReadRecord( + sourceId: string, + mangaId: string, + record: MangaReadRecord +): Promise { const key = generateStorageKey(sourceId, mangaId); if (STORAGE_TYPE !== 'localstorage') { @@ -1800,7 +1934,9 @@ export async function saveMangaReadRecord(sourceId: string, mangaId: string, rec cached[key] = record; const trimmedRecords = trimMangaReadRecords(cached); cacheManager.cacheMangaReadRecords(trimmedRecords); - window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: trimmedRecords })); + window.dispatchEvent( + new CustomEvent('mangaHistoryUpdated', { detail: trimmedRecords }) + ); try { await fetchWithAuth('/api/manga/history', { @@ -1819,20 +1955,29 @@ export async function saveMangaReadRecord(sourceId: string, mangaId: string, rec allRecords[key] = record; const trimmedRecords = trimMangaReadRecords(allRecords); localStorage.setItem(MANGA_HISTORY_KEY, JSON.stringify(trimmedRecords)); - window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: trimmedRecords })); + window.dispatchEvent( + new CustomEvent('mangaHistoryUpdated', { detail: trimmedRecords }) + ); } -export async function deleteMangaReadRecord(sourceId: string, mangaId: string): Promise { +export async function deleteMangaReadRecord( + sourceId: string, + mangaId: string +): Promise { const key = generateStorageKey(sourceId, mangaId); if (STORAGE_TYPE !== 'localstorage') { const cached = cacheManager.getCachedMangaReadRecords() || {}; delete cached[key]; cacheManager.cacheMangaReadRecords(cached); - window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: cached })); + window.dispatchEvent( + new CustomEvent('mangaHistoryUpdated', { detail: cached }) + ); try { - await fetchWithAuth(`/api/manga/history?key=${encodeURIComponent(key)}`, { method: 'DELETE' }); + await fetchWithAuth(`/api/manga/history?key=${encodeURIComponent(key)}`, { + method: 'DELETE', + }); } catch (err) { await handleDatabaseOperationFailure('mangaHistory', err); throw err; @@ -1843,13 +1988,17 @@ export async function deleteMangaReadRecord(sourceId: string, mangaId: string): const allRecords = await getAllMangaReadRecords(); delete allRecords[key]; localStorage.setItem(MANGA_HISTORY_KEY, JSON.stringify(allRecords)); - window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: allRecords })); + window.dispatchEvent( + new CustomEvent('mangaHistoryUpdated', { detail: allRecords }) + ); } export async function clearAllMangaReadRecords(): Promise { if (STORAGE_TYPE !== 'localstorage') { cacheManager.cacheMangaReadRecords({}); - window.dispatchEvent(new CustomEvent('mangaHistoryUpdated', { detail: {} })); + window.dispatchEvent( + new CustomEvent('mangaHistoryUpdated', { detail: {} }) + ); try { await fetchWithAuth('/api/manga/history', { method: 'DELETE' }); } catch (err) { @@ -1886,15 +2035,21 @@ export async function refreshAllCache(): Promise { // 使用 Promise 缓存防止并发重复刷新 await cacheManager.getOrCreateRequest('refresh-all-cache', async () => { // 并行刷新所有数据 - const [playRecords, favorites, mangaShelf, mangaHistory, searchHistory, skipConfigs] = - await Promise.allSettled([ - fetchFromApi>(`/api/playrecords`), - fetchFromApi>(`/api/favorites`), - fetchFromApi>(`/api/manga/shelf`), - fetchFromApi>(`/api/manga/history`), - fetchFromApi(`/api/searchhistory`), - fetchFromApi>(`/api/skipconfigs`), - ]); + const [ + playRecords, + favorites, + mangaShelf, + mangaHistory, + searchHistory, + skipConfigs, + ] = await Promise.allSettled([ + fetchFromApi>(`/api/playrecords`), + fetchFromApi>(`/api/favorites`), + fetchFromApi>(`/api/manga/shelf`), + fetchFromApi>(`/api/manga/history`), + fetchFromApi(`/api/searchhistory`), + fetchFromApi>(`/api/skipconfigs`), + ]); if (playRecords.status === 'fulfilled') { cacheManager.cachePlayRecords(playRecords.value); @@ -2020,7 +2175,7 @@ export function subscribeToDataUpdates( callback: (data: T) => void ): () => void { if (typeof window === 'undefined') { - return () => { }; + return () => {}; } const handleUpdate = (event: CustomEvent) => { @@ -2425,7 +2580,10 @@ export async function saveDanmakuFilterConfig( } try { - localStorage.setItem('moontv_danmaku_filter_config', JSON.stringify(config)); + localStorage.setItem( + 'moontv_danmaku_filter_config', + JSON.stringify(config) + ); window.dispatchEvent( new CustomEvent('danmakuFilterConfigUpdated', { detail: config, @@ -2444,7 +2602,9 @@ export async function saveDanmakuFilterConfig( * 获取全部音乐播放记录。 * 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。 */ -export async function getAllMusicPlayRecords(): Promise> { +export async function getAllMusicPlayRecords(): Promise< + Record +> { // 服务器端渲染阶段直接返回空 if (typeof window === 'undefined') { return {}; @@ -2594,9 +2754,12 @@ export async function deleteMusicPlayRecord( // 异步同步到数据库 try { - await fetchWithAuth(`/api/music/playrecords?key=${encodeURIComponent(key)}`, { - method: 'DELETE', - }); + await fetchWithAuth( + `/api/music/playrecords?key=${encodeURIComponent(key)}`, + { + method: 'DELETE', + } + ); } catch (err) { console.error('删除音乐播放记录失败:', err); triggerGlobalError('删除音乐播放记录失败'); @@ -2701,7 +2864,10 @@ export async function saveEpisodeFilterConfig( try { const normalizedConfig = normalizeEpisodeFilterConfig(config); - localStorage.setItem('moontv_episode_filter_config', JSON.stringify(normalizedConfig)); + localStorage.setItem( + 'moontv_episode_filter_config', + JSON.stringify(normalizedConfig) + ); window.dispatchEvent( new CustomEvent('episodeFilterConfigUpdated', { detail: normalizedConfig, diff --git a/src/lib/db.ts b/src/lib/db.ts index 8ecf5b1..fa4c0aa 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -5,9 +5,19 @@ import { MusicPlayRecord } from './db.client'; import { KvrocksStorage } from './kvrocks.db'; import { MangaReadRecord, MangaShelfItem } from './manga.types'; import { BookReadRecord, BookShelfItem } from './book.types'; -import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2'; +import { + MusicV2HistoryRecord, + MusicV2PlaylistItem, + MusicV2PlaylistRecord, +} from './music-v2'; import { RedisStorage } from './redis.db'; -import { DanmakuFilterConfig,Favorite, IStorage, PlayRecord, SkipConfig } from './types'; +import { + DanmakuFilterConfig, + Favorite, + IStorage, + PlayRecord, + SkipConfig, +} from './types'; import { UpstashRedisStorage } from './upstash.db'; // storage type 常量: 'localstorage' | 'redis' | 'upstash' | 'kvrocks' | 'd1' | 'postgres',默认 'localstorage' @@ -77,36 +87,44 @@ function getD1Adapter(): any { const { CloudflareD1Adapter, SQLiteAdapter } = require('./d1-adapter'); // 检查是否为 Cloudflare 构建 - const isCloudflare = process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare'; + const isCloudflare = + process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare'; // 生产环境:Cloudflare Workers/Pages if (isCloudflare) { // 创建一个懒加载的适配器,延迟到实际使用时才获取 D1 绑定 let cachedAdapter: any = null; - return new Proxy({}, { - get(target, prop) { - // 懒加载:第一次访问时才获取真实的 D1 适配器 - if (!cachedAdapter) { - try { - const { getCloudflareContext } = require('@opennextjs/cloudflare'); - const { env } = getCloudflareContext(); + return new Proxy( + {}, + { + get(target, prop) { + // 懒加载:第一次访问时才获取真实的 D1 适配器 + if (!cachedAdapter) { + try { + const { + getCloudflareContext, + } = require('@opennextjs/cloudflare'); + const { env } = getCloudflareContext(); - if (!env.DB) { - throw new Error('D1 database binding (DB) not found in Cloudflare environment'); + if (!env.DB) { + throw new Error( + 'D1 database binding (DB) not found in Cloudflare environment' + ); + } + + console.log('Using Cloudflare D1 database'); + cachedAdapter = new CloudflareD1Adapter(env.DB); + } catch (error) { + console.error('Failed to initialize Cloudflare D1:', error); + throw error; } - - console.log('Using Cloudflare D1 database'); - cachedAdapter = new CloudflareD1Adapter(env.DB); - } catch (error) { - console.error('Failed to initialize Cloudflare D1:', error); - throw error; } - } - return cachedAdapter[prop]; + return cachedAdapter[prop]; + }, } - }); + ); } // 开发环境:better-sqlite3 @@ -114,7 +132,8 @@ function getD1Adapter(): any { const path = require('path'); const dbPath = - process.env.SQLITE_DB_PATH || path.join(process.cwd(), '.data', 'moontv.db'); + process.env.SQLITE_DB_PATH || + path.join(process.cwd(), '.data', 'moontv.db'); const db = new Database(dbPath); db.pragma('journal_mode = WAL'); // 启用 WAL 模式提升性能 @@ -185,6 +204,10 @@ export class DbManager { await this.storage.deletePlayRecord(userName, key); } + async deletePlayRecords(userName: string, keys: string[]): Promise { + await this.storage.deletePlayRecords(userName, keys); + } + // 收藏相关方法 async getFavorite( userName: string, @@ -280,13 +303,19 @@ export class DbManager { return []; } - async upsertMusicV2History(userName: string, record: MusicV2HistoryRecord): Promise { + async upsertMusicV2History( + userName: string, + record: MusicV2HistoryRecord + ): Promise { if (typeof (this.storage as any).upsertMusicV2History === 'function') { await (this.storage as any).upsertMusicV2History(userName, record); } } - async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise { + async batchUpsertMusicV2History( + userName: string, + records: MusicV2HistoryRecord[] + ): Promise { if (typeof (this.storage as any).batchUpsertMusicV2History === 'function') { await (this.storage as any).batchUpsertMusicV2History(userName, records); } @@ -307,21 +336,25 @@ export class DbManager { // Music V2 歌单相关 async createMusicV2Playlist( userName: string, - playlist: { id: string; name: string; description?: string; cover?: string; } + playlist: { id: string; name: string; description?: string; cover?: string } ): Promise { if (typeof (this.storage as any).createMusicV2Playlist === 'function') { await (this.storage as any).createMusicV2Playlist(userName, playlist); } } - async getMusicV2Playlist(playlistId: string): Promise { + async getMusicV2Playlist( + playlistId: string + ): Promise { if (typeof (this.storage as any).getMusicV2Playlist === 'function') { return (this.storage as any).getMusicV2Playlist(playlistId); } return null; } - async listMusicV2Playlists(userName: string): Promise { + async listMusicV2Playlists( + userName: string + ): Promise { if (typeof (this.storage as any).listMusicV2Playlists === 'function') { return (this.storage as any).listMusicV2Playlists(userName); } @@ -330,7 +363,12 @@ export class DbManager { async updateMusicV2Playlist( playlistId: string, - updates: { name?: string; description?: string; cover?: string; song_count?: number; } + updates: { + name?: string; + description?: string; + cover?: string; + song_count?: number; + } ): Promise { if (typeof (this.storage as any).updateMusicV2Playlist === 'function') { await (this.storage as any).updateMusicV2Playlist(playlistId, updates); @@ -343,26 +381,37 @@ export class DbManager { } } - async addMusicV2PlaylistItem(playlistId: string, item: MusicV2PlaylistItem): Promise { + async addMusicV2PlaylistItem( + playlistId: string, + item: MusicV2PlaylistItem + ): Promise { if (typeof (this.storage as any).addMusicV2PlaylistItem === 'function') { await (this.storage as any).addMusicV2PlaylistItem(playlistId, item); } } - async removeMusicV2PlaylistItem(playlistId: string, songId: string): Promise { + async removeMusicV2PlaylistItem( + playlistId: string, + songId: string + ): Promise { if (typeof (this.storage as any).removeMusicV2PlaylistItem === 'function') { await (this.storage as any).removeMusicV2PlaylistItem(playlistId, songId); } } - async listMusicV2PlaylistItems(playlistId: string): Promise { + async listMusicV2PlaylistItems( + playlistId: string + ): Promise { if (typeof (this.storage as any).listMusicV2PlaylistItems === 'function') { return (this.storage as any).listMusicV2PlaylistItems(playlistId); } return []; } - async hasMusicV2PlaylistItem(playlistId: string, songId: string): Promise { + async hasMusicV2PlaylistItem( + playlistId: string, + songId: string + ): Promise { if (typeof (this.storage as any).hasMusicV2PlaylistItem === 'function') { return (this.storage as any).hasMusicV2PlaylistItem(playlistId, songId); } @@ -440,7 +489,11 @@ export class DbManager { songId: string ): Promise { if (typeof (this.storage as any).removeSongFromPlaylist === 'function') { - await (this.storage as any).removeSongFromPlaylist(playlistId, platform, songId); + await (this.storage as any).removeSongFromPlaylist( + playlistId, + platform, + songId + ); } } @@ -457,7 +510,11 @@ export class DbManager { songId: string ): Promise { if (typeof (this.storage as any).isSongInPlaylist === 'function') { - return (this.storage as any).isSongInPlaylist(playlistId, platform, songId); + return (this.storage as any).isSongInPlaylist( + playlistId, + platform, + songId + ); } return false; } @@ -489,7 +546,14 @@ export class DbManager { enabledApis?: string[] ): Promise { if (typeof (this.storage as any).createUserV2 === 'function') { - await (this.storage as any).createUserV2(userName, password, role, tags, oidcSub, enabledApis); + await (this.storage as any).createUserV2( + userName, + password, + role, + tags, + oidcSub, + enabledApis + ); } } @@ -570,7 +634,12 @@ export class DbManager { total: number; }> { if (typeof (this.storage as any).getUserListV2 === 'function') { - return (this.storage as any).getUserListV2(offset, limit, ownerUsername, search); + return (this.storage as any).getUserListV2( + offset, + limit, + ownerUsername, + search + ); } return { users: [], total: 0 }; } @@ -670,7 +739,9 @@ export class DbManager { else { try { if ((this.storage as any).client) { - const storedPassword = await (this.storage as any).client.get(`u:${user.username}:pwd`); + const storedPassword = await (this.storage as any).client.get( + `u:${user.username}:pwd` + ); if (storedPassword) { password = storedPassword; console.log(`用户 ${user.username} 使用旧密码迁移`); @@ -683,7 +754,10 @@ export class DbManager { password = 'defaultPassword123'; } } catch (err) { - console.error(`获取用户 ${user.username} 的密码失败,使用默认密码`, err); + console.error( + `获取用户 ${user.username} 的密码失败,使用默认密码`, + err + ); password = 'defaultPassword123'; } } @@ -732,71 +806,171 @@ export class DbManager { } // ---------- 漫画书架 ---------- - async getMangaShelf(userName: string, sourceId: string, mangaId: string): Promise { - return this.storage.getMangaShelf(userName, generateStorageKey(sourceId, mangaId)); + async getMangaShelf( + userName: string, + sourceId: string, + mangaId: string + ): Promise { + return this.storage.getMangaShelf( + userName, + generateStorageKey(sourceId, mangaId) + ); } - async saveMangaShelf(userName: string, sourceId: string, mangaId: string, item: MangaShelfItem): Promise { - await this.storage.setMangaShelf(userName, generateStorageKey(sourceId, mangaId), item); + async saveMangaShelf( + userName: string, + sourceId: string, + mangaId: string, + item: MangaShelfItem + ): Promise { + await this.storage.setMangaShelf( + userName, + generateStorageKey(sourceId, mangaId), + item + ); } - async getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }> { + async getAllMangaShelf( + userName: string + ): Promise<{ [key: string]: MangaShelfItem }> { return this.storage.getAllMangaShelf(userName); } - async deleteMangaShelf(userName: string, sourceId: string, mangaId: string): Promise { - await this.storage.deleteMangaShelf(userName, generateStorageKey(sourceId, mangaId)); + async deleteMangaShelf( + userName: string, + sourceId: string, + mangaId: string + ): Promise { + await this.storage.deleteMangaShelf( + userName, + generateStorageKey(sourceId, mangaId) + ); } // ---------- 漫画阅读历史 ---------- - async getMangaReadRecord(userName: string, sourceId: string, mangaId: string): Promise { - return this.storage.getMangaReadRecord(userName, generateStorageKey(sourceId, mangaId)); + async getMangaReadRecord( + userName: string, + sourceId: string, + mangaId: string + ): Promise { + return this.storage.getMangaReadRecord( + userName, + generateStorageKey(sourceId, mangaId) + ); } - async saveMangaReadRecord(userName: string, sourceId: string, mangaId: string, record: MangaReadRecord): Promise { - await this.storage.setMangaReadRecord(userName, generateStorageKey(sourceId, mangaId), record); + async saveMangaReadRecord( + userName: string, + sourceId: string, + mangaId: string, + record: MangaReadRecord + ): Promise { + await this.storage.setMangaReadRecord( + userName, + generateStorageKey(sourceId, mangaId), + record + ); } - async getAllMangaReadRecords(userName: string): Promise<{ [key: string]: MangaReadRecord }> { + async getAllMangaReadRecords( + userName: string + ): Promise<{ [key: string]: MangaReadRecord }> { return this.storage.getAllMangaReadRecords(userName); } - async deleteMangaReadRecord(userName: string, sourceId: string, mangaId: string): Promise { - await this.storage.deleteMangaReadRecord(userName, generateStorageKey(sourceId, mangaId)); + async deleteMangaReadRecord( + userName: string, + sourceId: string, + mangaId: string + ): Promise { + await this.storage.deleteMangaReadRecord( + userName, + generateStorageKey(sourceId, mangaId) + ); } // ---------- 电子书书架 ---------- - async getBookShelf(userName: string, sourceId: string, bookId: string): Promise { - return this.storage.getBookShelf(userName, generateStorageKey(sourceId, bookId)); + async getBookShelf( + userName: string, + sourceId: string, + bookId: string + ): Promise { + return this.storage.getBookShelf( + userName, + generateStorageKey(sourceId, bookId) + ); } - async saveBookShelf(userName: string, sourceId: string, bookId: string, item: BookShelfItem): Promise { - await this.storage.setBookShelf(userName, generateStorageKey(sourceId, bookId), item); + async saveBookShelf( + userName: string, + sourceId: string, + bookId: string, + item: BookShelfItem + ): Promise { + await this.storage.setBookShelf( + userName, + generateStorageKey(sourceId, bookId), + item + ); } - async getAllBookShelf(userName: string): Promise<{ [key: string]: BookShelfItem }> { + async getAllBookShelf( + userName: string + ): Promise<{ [key: string]: BookShelfItem }> { return this.storage.getAllBookShelf(userName); } - async deleteBookShelf(userName: string, sourceId: string, bookId: string): Promise { - await this.storage.deleteBookShelf(userName, generateStorageKey(sourceId, bookId)); + async deleteBookShelf( + userName: string, + sourceId: string, + bookId: string + ): Promise { + await this.storage.deleteBookShelf( + userName, + generateStorageKey(sourceId, bookId) + ); } // ---------- 电子书阅读历史 ---------- - async getBookReadRecord(userName: string, sourceId: string, bookId: string): Promise { - return this.storage.getBookReadRecord(userName, generateStorageKey(sourceId, bookId)); + async getBookReadRecord( + userName: string, + sourceId: string, + bookId: string + ): Promise { + return this.storage.getBookReadRecord( + userName, + generateStorageKey(sourceId, bookId) + ); } - async saveBookReadRecord(userName: string, sourceId: string, bookId: string, record: BookReadRecord): Promise { - await this.storage.setBookReadRecord(userName, generateStorageKey(sourceId, bookId), record); + async saveBookReadRecord( + userName: string, + sourceId: string, + bookId: string, + record: BookReadRecord + ): Promise { + await this.storage.setBookReadRecord( + userName, + generateStorageKey(sourceId, bookId), + record + ); } - async getAllBookReadRecords(userName: string): Promise<{ [key: string]: BookReadRecord }> { + async getAllBookReadRecords( + userName: string + ): Promise<{ [key: string]: BookReadRecord }> { return this.storage.getAllBookReadRecords(userName); } - async deleteBookReadRecord(userName: string, sourceId: string, bookId: string): Promise { - await this.storage.deleteBookReadRecord(userName, generateStorageKey(sourceId, bookId)); + async deleteBookReadRecord( + userName: string, + sourceId: string, + bookId: string + ): Promise { + await this.storage.deleteBookReadRecord( + userName, + generateStorageKey(sourceId, bookId) + ); } // 获取全部用户名 @@ -864,7 +1038,9 @@ export class DbManager { } // ---------- 弹幕过滤配置 ---------- - async getDanmakuFilterConfig(userName: string): Promise { + async getDanmakuFilterConfig( + userName: string + ): Promise { if (typeof (this.storage as any).getDanmakuFilterConfig === 'function') { return (this.storage as any).getDanmakuFilterConfig(userName); } diff --git a/src/lib/postgres.db.ts b/src/lib/postgres.db.ts index a8a09b9..ccedd59 100644 --- a/src/lib/postgres.db.ts +++ b/src/lib/postgres.db.ts @@ -21,7 +21,11 @@ import { AdminConfig } from './admin.types'; import { MangaReadRecord, MangaShelfItem } from './manga.types'; import { BookReadRecord, BookShelfItem } from './book.types'; import { DatabaseAdapter } from './d1-adapter'; -import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2'; +import { + MusicV2HistoryRecord, + MusicV2PlaylistItem, + MusicV2PlaylistRecord, +} from './music-v2'; /** * Vercel Postgres 存储实现 @@ -60,7 +64,10 @@ export class PostgresStorage implements IStorage { try { const result = await this.db.prepare(statement).run(); if (!result.success && result.error) { - console.warn('PostgresStorage.ensureMangaShelfColumns warning:', result.error); + console.warn( + 'PostgresStorage.ensureMangaShelfColumns warning:', + result.error + ); } } catch (err) { console.warn('PostgresStorage.ensureMangaShelfColumns warning:', err); @@ -70,7 +77,10 @@ export class PostgresStorage implements IStorage { // ==================== 播放记录 ==================== - async getPlayRecord(userName: string, key: string): Promise { + async getPlayRecord( + userName: string, + key: string + ): Promise { try { const result = await this.db .prepare('SELECT * FROM play_records WHERE username = $1 AND key = $2') @@ -85,10 +95,15 @@ export class PostgresStorage implements IStorage { } } - async setPlayRecord(userName: string, key: string, record: PlayRecord): Promise { + async setPlayRecord( + userName: string, + key: string, + record: PlayRecord + ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO play_records ( username, key, title, source_name, cover, year, episode_index, total_episodes, play_time, total_time, @@ -107,7 +122,8 @@ export class PostgresStorage implements IStorage { save_time = EXCLUDED.save_time, search_title = EXCLUDED.search_title, new_episodes = EXCLUDED.new_episodes - `) + ` + ) .bind( userName, key, @@ -130,10 +146,14 @@ export class PostgresStorage implements IStorage { } } - async getAllPlayRecords(userName: string): Promise<{ [key: string]: PlayRecord }> { + async getAllPlayRecords( + userName: string + ): Promise<{ [key: string]: PlayRecord }> { try { const results = await this.db - .prepare('SELECT * FROM play_records WHERE username = $1 ORDER BY save_time DESC') + .prepare( + 'SELECT * FROM play_records WHERE username = $1 ORDER BY save_time DESC' + ) .bind(userName) .all(); @@ -163,14 +183,39 @@ export class PostgresStorage implements IStorage { } } + async deletePlayRecords(userName: string, keys: string[]): Promise { + const uniqueKeys = Array.from(new Set(keys)).filter(Boolean); + if (uniqueKeys.length === 0) return; + + try { + const placeholders = uniqueKeys + .map((_, index) => `$${index + 2}`) + .join(','); + await this.db + .prepare( + `DELETE FROM play_records WHERE username = $1 AND key IN (${placeholders})` + ) + .bind(userName, ...uniqueKeys) + .run(); + } catch (err) { + console.error('PostgresStorage.deletePlayRecords error:', err); + throw err; + } + } + async cleanupOldPlayRecords(userName: string): Promise { try { - const maxRecords = parseInt(process.env.MAX_PLAY_RECORDS_PER_USER || '100', 10); + const maxRecords = parseInt( + process.env.MAX_PLAY_RECORDS_PER_USER || '100', + 10 + ); const threshold = maxRecords + 10; // 检查记录数量 const countResult = await this.db - .prepare('SELECT COUNT(*) as count FROM play_records WHERE username = $1') + .prepare( + 'SELECT COUNT(*) as count FROM play_records WHERE username = $1' + ) .bind(userName) .first(); @@ -179,7 +224,8 @@ export class PostgresStorage implements IStorage { // 删除超出限制的旧记录 await this.db - .prepare(` + .prepare( + ` DELETE FROM play_records WHERE username = $1 AND key NOT IN ( @@ -188,11 +234,14 @@ export class PostgresStorage implements IStorage { ORDER BY save_time DESC LIMIT $2 ) - `) + ` + ) .bind(userName, maxRecords) .run(); - console.log(`PostgresStorage: Cleaned up old play records for user ${userName}`); + console.log( + `PostgresStorage: Cleaned up old play records for user ${userName}` + ); } catch (err) { console.error('PostgresStorage.cleanupOldPlayRecords error:', err); throw err; @@ -227,10 +276,15 @@ export class PostgresStorage implements IStorage { } } - async setFavorite(userName: string, key: string, favorite: Favorite): Promise { + async setFavorite( + userName: string, + key: string, + favorite: Favorite + ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO favorites ( username, key, source_name, total_episodes, title, year, cover, save_time, search_title, origin, @@ -248,7 +302,8 @@ export class PostgresStorage implements IStorage { origin = EXCLUDED.origin, is_completed = EXCLUDED.is_completed, vod_remarks = EXCLUDED.vod_remarks - `) + ` + ) .bind( userName, key, @@ -270,10 +325,14 @@ export class PostgresStorage implements IStorage { } } - async getAllFavorites(userName: string): Promise<{ [key: string]: Favorite }> { + async getAllFavorites( + userName: string + ): Promise<{ [key: string]: Favorite }> { try { const results = await this.db - .prepare('SELECT * FROM favorites WHERE username = $1 ORDER BY save_time DESC') + .prepare( + 'SELECT * FROM favorites WHERE username = $1 ORDER BY save_time DESC' + ) .bind(userName) .all(); @@ -360,12 +419,17 @@ export class PostgresStorage implements IStorage { async verifyUser(userName: string, password: string): Promise { try { // 检查是否是环境变量中的管理员 - if (userName === process.env.USERNAME && password === process.env.PASSWORD) { + if ( + userName === process.env.USERNAME && + password === process.env.PASSWORD + ) { return true; } const user = await this.db - .prepare('SELECT password_hash FROM users WHERE username = $1 AND banned = 0') + .prepare( + 'SELECT password_hash FROM users WHERE username = $1 AND banned = 0' + ) .bind(userName) .first(); @@ -461,12 +525,16 @@ export class PostgresStorage implements IStorage { banned: user.banned === 1, tags: user.tags ? JSON.parse(user.tags as string) : undefined, oidcSub: user.oidc_sub as string | undefined, - enabledApis: user.enabled_apis ? JSON.parse(user.enabled_apis as string) : undefined, + enabledApis: user.enabled_apis + ? JSON.parse(user.enabled_apis as string) + : undefined, created_at: user.created_at as number, playrecord_migrated: user.playrecord_migrated === 1, favorite_migrated: user.favorite_migrated === 1, skip_migrated: user.skip_migrated === 1, - last_movie_request_time: user.last_movie_request_time as number | undefined, + last_movie_request_time: user.last_movie_request_time as + | number + | undefined, email: user.email as string | undefined, emailNotifications: user.email_notifications === 1, }; @@ -491,13 +559,15 @@ export class PostgresStorage implements IStorage { // 为站长创建数据库记录 try { await this.db - .prepare(` + .prepare( + ` INSERT INTO users ( username, password_hash, role, banned, created_at, playrecord_migrated, favorite_migrated, skip_migrated ) VALUES ($1, $2, $3, 0, $4, 1, 1, 1) - `) + ` + ) .bind( userName, '', // 站长不需要密码哈希 @@ -536,14 +606,16 @@ export class PostgresStorage implements IStorage { const passwordHash = await this.hashPassword(password); await this.db - .prepare(` + .prepare( + ` INSERT INTO users ( username, password_hash, role, banned, tags, oidc_sub, enabled_apis, created_at, playrecord_migrated, favorite_migrated, skip_migrated ) VALUES ($1, $2, $3, 0, $4, $5, $6, $7, 1, 1, 1) - `) + ` + ) .bind( userName, passwordHash, @@ -584,7 +656,9 @@ export class PostgresStorage implements IStorage { // 获取总数 const countQuery = trimmedSearch ? this.db - .prepare('SELECT COUNT(*) as total FROM users WHERE username LIKE $1') + .prepare( + 'SELECT COUNT(*) as total FROM users WHERE username LIKE $1' + ) .bind(searchPattern) : this.db.prepare('SELECT COUNT(*) as total FROM users'); const countResult = await countQuery.first(); @@ -627,21 +701,25 @@ export class PostgresStorage implements IStorage { // 获取用户列表(按创建时间降序) const listQuery = trimmedSearch ? this.db - .prepare(` + .prepare( + ` SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at FROM users WHERE username LIKE $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 - `) + ` + ) .bind(searchPattern, actualLimit, actualOffset) : this.db - .prepare(` + .prepare( + ` SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2 - `) + ` + ) .bind(actualLimit, actualOffset); const result = await listQuery.all(); @@ -678,7 +756,9 @@ export class PostgresStorage implements IStorage { banned: user.banned === 1, tags: user.tags ? JSON.parse(user.tags as string) : undefined, oidcSub: user.oidc_sub as string | undefined, - enabledApis: user.enabled_apis ? JSON.parse(user.enabled_apis as string) : undefined, + enabledApis: user.enabled_apis + ? JSON.parse(user.enabled_apis as string) + : undefined, created_at: user.created_at as number, }); } @@ -749,7 +829,11 @@ export class PostgresStorage implements IStorage { values.push(userName); await this.db - .prepare(`UPDATE users SET ${fields.join(', ')} WHERE username = $${paramIndex}`) + .prepare( + `UPDATE users SET ${fields.join( + ', ' + )} WHERE username = $${paramIndex}` + ) .bind(...values) .run(); @@ -825,10 +909,12 @@ export class PostgresStorage implements IStorage { try { // Postgres 支持 JSON 查询 const result = await this.db - .prepare(` + .prepare( + ` SELECT username FROM users WHERE tags::jsonb ? $1 - `) + ` + ) .bind(tagName) .all(); @@ -855,7 +941,10 @@ export class PostgresStorage implements IStorage { } } - async setUserPasswordHash(userName: string, passwordHash: string): Promise { + async setUserPasswordHash( + userName: string, + passwordHash: string + ): Promise { try { await this.db .prepare('UPDATE users SET password_hash = $1 WHERE username = $2') @@ -879,14 +968,16 @@ export class PostgresStorage implements IStorage { ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO users ( username, password_hash, role, banned, tags, oidc_sub, enabled_apis, created_at, playrecord_migrated, favorite_migrated, skip_migrated ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, 1, 1) - `) + ` + ) .bind( userName, passwordHash, @@ -943,15 +1034,23 @@ export class PostgresStorage implements IStorage { return result?.email_notifications === 1; } catch (err) { - console.error('PostgresStorage.getEmailNotificationPreference error:', err); + console.error( + 'PostgresStorage.getEmailNotificationPreference error:', + err + ); return true; // 默认开启 } } - async setEmailNotificationPreference(userName: string, enabled: boolean): Promise { + async setEmailNotificationPreference( + userName: string, + enabled: boolean + ): Promise { try { await this.db - .prepare('UPDATE users SET email_notifications = $1 WHERE username = $2') + .prepare( + 'UPDATE users SET email_notifications = $1 WHERE username = $2' + ) .bind(enabled ? 1 : 0, userName) .run(); @@ -959,7 +1058,10 @@ export class PostgresStorage implements IStorage { const { userInfoCache } = await import('./user-cache'); userInfoCache.delete(userName); } catch (err) { - console.error('PostgresStorage.setEmailNotificationPreference error:', err); + console.error( + 'PostgresStorage.setEmailNotificationPreference error:', + err + ); throw err; } } @@ -969,7 +1071,9 @@ export class PostgresStorage implements IStorage { async getTvboxSubscribeToken(userName: string): Promise { try { const result = await this.db - .prepare('SELECT tvbox_subscribe_token FROM users_v2 WHERE username = $1') + .prepare( + 'SELECT tvbox_subscribe_token FROM users_v2 WHERE username = $1' + ) .bind(userName) .first(); @@ -983,7 +1087,9 @@ export class PostgresStorage implements IStorage { async setTvboxSubscribeToken(userName: string, token: string): Promise { try { await this.db - .prepare('UPDATE users_v2 SET tvbox_subscribe_token = $1 WHERE username = $2') + .prepare( + 'UPDATE users_v2 SET tvbox_subscribe_token = $1 WHERE username = $2' + ) .bind(token, userName) .run(); @@ -999,7 +1105,9 @@ export class PostgresStorage implements IStorage { async getUsernameByTvboxToken(token: string): Promise { try { const result = await this.db - .prepare('SELECT username FROM users_v2 WHERE tvbox_subscribe_token = $1') + .prepare( + 'SELECT username FROM users_v2 WHERE tvbox_subscribe_token = $1' + ) .bind(token) .first(); @@ -1015,7 +1123,9 @@ export class PostgresStorage implements IStorage { async getMusicPlayRecord(userName: string, key: string): Promise { try { const result = await this.db - .prepare('SELECT * FROM music_play_records WHERE username = $1 AND key = $2') + .prepare( + 'SELECT * FROM music_play_records WHERE username = $1 AND key = $2' + ) .bind(userName, key) .first(); @@ -1038,10 +1148,15 @@ export class PostgresStorage implements IStorage { } } - async setMusicPlayRecord(userName: string, key: string, record: any): Promise { + async setMusicPlayRecord( + userName: string, + key: string, + record: any + ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO music_play_records (username, key, platform, song_id, name, artist, album, pic, play_time, duration, save_time) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT(username, key) DO UPDATE SET @@ -1052,7 +1167,8 @@ export class PostgresStorage implements IStorage { play_time = EXCLUDED.play_time, duration = EXCLUDED.duration, save_time = EXCLUDED.save_time - `) + ` + ) .bind( userName, key, @@ -1073,14 +1189,18 @@ export class PostgresStorage implements IStorage { } } - async batchSetMusicPlayRecords(userName: string, records: { key: string; record: any }[]): Promise { + async batchSetMusicPlayRecords( + userName: string, + records: { key: string; record: any }[] + ): Promise { if (records.length === 0) return; try { // 使用批量插入,Postgres 支持 batch 操作 const statements = records.map(({ key, record }) => this.db - .prepare(` + .prepare( + ` INSERT INTO music_play_records (username, key, platform, song_id, name, artist, album, pic, play_time, duration, save_time) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT(username, key) DO UPDATE SET @@ -1093,7 +1213,8 @@ export class PostgresStorage implements IStorage { play_time = EXCLUDED.play_time, duration = EXCLUDED.duration, save_time = EXCLUDED.save_time - `) + ` + ) .bind( userName, key, @@ -1118,10 +1239,14 @@ export class PostgresStorage implements IStorage { } } - async getAllMusicPlayRecords(userName: string): Promise<{ [key: string]: any }> { + async getAllMusicPlayRecords( + userName: string + ): Promise<{ [key: string]: any }> { try { const results = await this.db - .prepare('SELECT * FROM music_play_records WHERE username = $1 ORDER BY save_time DESC') + .prepare( + 'SELECT * FROM music_play_records WHERE username = $1 ORDER BY save_time DESC' + ) .bind(userName) .all(); @@ -1151,7 +1276,9 @@ export class PostgresStorage implements IStorage { async deleteMusicPlayRecord(userName: string, key: string): Promise { try { await this.db - .prepare('DELETE FROM music_play_records WHERE username = $1 AND key = $2') + .prepare( + 'DELETE FROM music_play_records WHERE username = $1 AND key = $2' + ) .bind(userName, key) .run(); } catch (err) { @@ -1174,19 +1301,24 @@ export class PostgresStorage implements IStorage { // ==================== 音乐歌单相关 ==================== - async createMusicPlaylist(userName: string, playlist: { - id: string; - name: string; - description?: string; - cover?: string; - }): Promise { + async createMusicPlaylist( + userName: string, + playlist: { + id: string; + name: string; + description?: string; + cover?: string; + } + ): Promise { try { const now = Date.now(); await this.db - .prepare(` + .prepare( + ` INSERT INTO music_playlists (id, username, name, description, cover, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7) - `) + ` + ) .bind( playlist.id, userName, @@ -1230,7 +1362,9 @@ export class PostgresStorage implements IStorage { async getUserMusicPlaylists(userName: string): Promise { try { const results = await this.db - .prepare('SELECT * FROM music_playlists WHERE username = $1 ORDER BY created_at DESC') + .prepare( + 'SELECT * FROM music_playlists WHERE username = $1 ORDER BY created_at DESC' + ) .bind(userName) .all(); @@ -1251,11 +1385,14 @@ export class PostgresStorage implements IStorage { } } - async updateMusicPlaylist(playlistId: string, updates: { - name?: string; - description?: string; - cover?: string; - }): Promise { + async updateMusicPlaylist( + playlistId: string, + updates: { + name?: string; + description?: string; + cover?: string; + } + ): Promise { try { const setClauses: string[] = []; const values: any[] = []; @@ -1282,7 +1419,11 @@ export class PostgresStorage implements IStorage { values.push(playlistId); await this.db - .prepare(`UPDATE music_playlists SET ${setClauses.join(', ')} WHERE id = $${paramIndex}`) + .prepare( + `UPDATE music_playlists SET ${setClauses.join( + ', ' + )} WHERE id = $${paramIndex}` + ) .bind(...values) .run(); } catch (err) { @@ -1303,28 +1444,34 @@ export class PostgresStorage implements IStorage { } } - async addSongToPlaylist(playlistId: string, song: { - platform: string; - id: string; - name: string; - artist: string; - album?: string; - pic?: string; - duration: number; - }): Promise { + async addSongToPlaylist( + playlistId: string, + song: { + platform: string; + id: string; + name: string; + artist: string; + album?: string; + pic?: string; + duration: number; + } + ): Promise { try { const now = Date.now(); // 获取当前最大的 sort_order const maxSortResult = await this.db - .prepare('SELECT MAX(sort_order) as max_sort FROM music_playlist_songs WHERE playlist_id = $1') + .prepare( + 'SELECT MAX(sort_order) as max_sort FROM music_playlist_songs WHERE playlist_id = $1' + ) .bind(playlistId) .first(); - const nextSortOrder = (maxSortResult?.max_sort as number || 0) + 1; + const nextSortOrder = ((maxSortResult?.max_sort as number) || 0) + 1; await this.db - .prepare(` + .prepare( + ` INSERT INTO music_playlist_songs (playlist_id, platform, song_id, name, artist, album, pic, duration, added_at, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT(playlist_id, platform, song_id) DO UPDATE SET @@ -1333,7 +1480,8 @@ export class PostgresStorage implements IStorage { album = EXCLUDED.album, pic = EXCLUDED.pic, duration = EXCLUDED.duration - `) + ` + ) .bind( playlistId, song.platform, @@ -1359,10 +1507,16 @@ export class PostgresStorage implements IStorage { } } - async removeSongFromPlaylist(playlistId: string, platform: string, songId: string): Promise { + async removeSongFromPlaylist( + playlistId: string, + platform: string, + songId: string + ): Promise { try { await this.db - .prepare('DELETE FROM music_playlist_songs WHERE playlist_id = $1 AND platform = $2 AND song_id = $3') + .prepare( + 'DELETE FROM music_playlist_songs WHERE playlist_id = $1 AND platform = $2 AND song_id = $3' + ) .bind(playlistId, platform, songId) .run(); @@ -1380,7 +1534,9 @@ export class PostgresStorage implements IStorage { async getPlaylistSongs(playlistId: string): Promise { try { const results = await this.db - .prepare('SELECT * FROM music_playlist_songs WHERE playlist_id = $1 ORDER BY sort_order ASC') + .prepare( + 'SELECT * FROM music_playlist_songs WHERE playlist_id = $1 ORDER BY sort_order ASC' + ) .bind(playlistId) .all(); @@ -1403,11 +1559,16 @@ export class PostgresStorage implements IStorage { } } - async updatePlaylistSongOrder(playlistId: string, songOrders: Array<{ platform: string; songId: string; sortOrder: number }>): Promise { + async updatePlaylistSongOrder( + playlistId: string, + songOrders: Array<{ platform: string; songId: string; sortOrder: number }> + ): Promise { try { const statements = songOrders.map(({ platform, songId, sortOrder }) => this.db - .prepare('UPDATE music_playlist_songs SET sort_order = $1 WHERE playlist_id = $2 AND platform = $3 AND song_id = $4') + .prepare( + 'UPDATE music_playlist_songs SET sort_order = $1 WHERE playlist_id = $2 AND platform = $3 AND song_id = $4' + ) .bind(sortOrder, playlistId, platform, songId) ); @@ -1432,7 +1593,9 @@ export class PostgresStorage implements IStorage { try { const results = await this.db // 按队列顺序返回;当前播放项由最大 last_played_at 决定 - .prepare('SELECT * FROM music_v2_history WHERE username = $1 ORDER BY created_at ASC, id ASC') + .prepare( + 'SELECT * FROM music_v2_history WHERE username = $1 ORDER BY created_at ASC, id ASC' + ) .bind(userName) .all(); @@ -1461,10 +1624,14 @@ export class PostgresStorage implements IStorage { } } - async upsertMusicV2History(userName: string, record: MusicV2HistoryRecord): Promise { + async upsertMusicV2History( + userName: string, + record: MusicV2HistoryRecord + ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO music_v2_history ( username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec, play_progress_sec, last_played_at, play_count, last_quality, created_at, updated_at @@ -1484,7 +1651,8 @@ export class PostgresStorage implements IStorage { play_count = EXCLUDED.play_count, last_quality = EXCLUDED.last_quality, updated_at = EXCLUDED.updated_at - `) + ` + ) .bind( userName, record.songId, @@ -1510,7 +1678,10 @@ export class PostgresStorage implements IStorage { } } - async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise { + async batchUpsertMusicV2History( + userName: string, + records: MusicV2HistoryRecord[] + ): Promise { for (const record of records) { await this.upsertMusicV2History(userName, record); } @@ -1518,7 +1689,9 @@ export class PostgresStorage implements IStorage { async deleteMusicV2History(userName: string, songId: string): Promise { await this.db - .prepare('DELETE FROM music_v2_history WHERE username = $1 AND song_id = $2') + .prepare( + 'DELETE FROM music_v2_history WHERE username = $1 AND song_id = $2' + ) .bind(userName, songId) .run(); } @@ -1532,23 +1705,39 @@ export class PostgresStorage implements IStorage { // ==================== Music V2 歌单相关 ==================== - async createMusicV2Playlist(userName: string, playlist: { - id: string; - name: string; - description?: string; - cover?: string; - }): Promise { + async createMusicV2Playlist( + userName: string, + playlist: { + id: string; + name: string; + description?: string; + cover?: string; + } + ): Promise { const now = Date.now(); await this.db - .prepare(` + .prepare( + ` INSERT INTO music_v2_playlists (id, username, name, description, cover, song_count, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - `) - .bind(playlist.id, userName, playlist.name, playlist.description || null, playlist.cover || null, 0, now, now) + ` + ) + .bind( + playlist.id, + userName, + playlist.name, + playlist.description || null, + playlist.cover || null, + 0, + now, + now + ) .run(); } - async getMusicV2Playlist(playlistId: string): Promise { + async getMusicV2Playlist( + playlistId: string + ): Promise { const row: any = await this.db .prepare('SELECT * FROM music_v2_playlists WHERE id = $1') .bind(playlistId) @@ -1566,9 +1755,13 @@ export class PostgresStorage implements IStorage { }; } - async listMusicV2Playlists(userName: string): Promise { + async listMusicV2Playlists( + userName: string + ): Promise { const results = await this.db - .prepare('SELECT * FROM music_v2_playlists WHERE username = $1 ORDER BY updated_at DESC') + .prepare( + 'SELECT * FROM music_v2_playlists WHERE username = $1 ORDER BY updated_at DESC' + ) .bind(userName) .all(); if (!results.results) return []; @@ -1584,12 +1777,15 @@ export class PostgresStorage implements IStorage { })); } - async updateMusicV2Playlist(playlistId: string, updates: { - name?: string; - description?: string; - cover?: string; - song_count?: number; - }): Promise { + async updateMusicV2Playlist( + playlistId: string, + updates: { + name?: string; + description?: string; + cover?: string; + song_count?: number; + } + ): Promise { const clauses: string[] = []; const values: any[] = []; let index = 1; @@ -1613,7 +1809,11 @@ export class PostgresStorage implements IStorage { values.push(Date.now()); values.push(playlistId); await this.db - .prepare(`UPDATE music_v2_playlists SET ${clauses.join(', ')} WHERE id = $${index}`) + .prepare( + `UPDATE music_v2_playlists SET ${clauses.join( + ', ' + )} WHERE id = $${index}` + ) .bind(...values) .run(); } @@ -1625,20 +1825,29 @@ export class PostgresStorage implements IStorage { .run(); } - async addMusicV2PlaylistItem(playlistId: string, item: MusicV2PlaylistItem): Promise { + async addMusicV2PlaylistItem( + playlistId: string, + item: MusicV2PlaylistItem + ): Promise { const playlist = await this.getMusicV2Playlist(playlistId); if (!playlist) { throw new Error('歌单不存在'); } const maxSort: any = await this.db - .prepare('SELECT MAX(sort_order) as max_sort FROM music_v2_playlist_items WHERE playlist_id = $1') + .prepare( + 'SELECT MAX(sort_order) as max_sort FROM music_v2_playlist_items WHERE playlist_id = $1' + ) .bind(playlistId) .first(); - const nextOrder = Math.max(item.sortOrder || 0, (maxSort?.max_sort as number || 0) + 1); + const nextOrder = Math.max( + item.sortOrder || 0, + ((maxSort?.max_sort as number) || 0) + 1 + ); const now = Date.now(); await this.db - .prepare(` + .prepare( + ` INSERT INTO music_v2_playlist_items ( playlist_id, username, song_id, source, songmid, name, artist, album, cover, duration_text, duration_sec, sort_order, added_at, updated_at ) @@ -1653,7 +1862,8 @@ export class PostgresStorage implements IStorage { duration_text = EXCLUDED.duration_text, duration_sec = EXCLUDED.duration_sec, updated_at = EXCLUDED.updated_at - `) + ` + ) .bind( playlistId, playlist.username, @@ -1679,9 +1889,14 @@ export class PostgresStorage implements IStorage { }); } - async removeMusicV2PlaylistItem(playlistId: string, songId: string): Promise { + async removeMusicV2PlaylistItem( + playlistId: string, + songId: string + ): Promise { await this.db - .prepare('DELETE FROM music_v2_playlist_items WHERE playlist_id = $1 AND song_id = $2') + .prepare( + 'DELETE FROM music_v2_playlist_items WHERE playlist_id = $1 AND song_id = $2' + ) .bind(playlistId, songId) .run(); const items = await this.listMusicV2PlaylistItems(playlistId); @@ -1691,9 +1906,13 @@ export class PostgresStorage implements IStorage { }); } - async listMusicV2PlaylistItems(playlistId: string): Promise { + async listMusicV2PlaylistItems( + playlistId: string + ): Promise { const results = await this.db - .prepare('SELECT * FROM music_v2_playlist_items WHERE playlist_id = $1 ORDER BY sort_order ASC, added_at ASC') + .prepare( + 'SELECT * FROM music_v2_playlist_items WHERE playlist_id = $1 ORDER BY sort_order ASC, added_at ASC' + ) .bind(playlistId) .all(); if (!results.results) return []; @@ -1714,9 +1933,14 @@ export class PostgresStorage implements IStorage { })); } - async hasMusicV2PlaylistItem(playlistId: string, songId: string): Promise { + async hasMusicV2PlaylistItem( + playlistId: string, + songId: string + ): Promise { const row = await this.db - .prepare('SELECT 1 FROM music_v2_playlist_items WHERE playlist_id = $1 AND song_id = $2 LIMIT 1') + .prepare( + 'SELECT 1 FROM music_v2_playlist_items WHERE playlist_id = $1 AND song_id = $2 LIMIT 1' + ) .bind(playlistId, songId) .first(); return row !== null; @@ -1727,7 +1951,9 @@ export class PostgresStorage implements IStorage { async getSearchHistory(userName: string): Promise { try { const results = await this.db - .prepare('SELECT keyword FROM search_history WHERE username = $1 ORDER BY timestamp DESC LIMIT 20') + .prepare( + 'SELECT keyword FROM search_history WHERE username = $1 ORDER BY timestamp DESC LIMIT 20' + ) .bind(userName) .all(); @@ -1745,24 +1971,29 @@ export class PostgresStorage implements IStorage { // 插入或更新时间戳 await this.db - .prepare(` + .prepare( + ` INSERT INTO search_history (username, keyword, timestamp) VALUES ($1, $2, $3) ON CONFLICT (username, keyword) DO UPDATE SET timestamp = EXCLUDED.timestamp - `) + ` + ) .bind(userName, keyword, timestamp) .run(); // 保持最多 20 条记录 const countResult = await this.db - .prepare('SELECT COUNT(*) as count FROM search_history WHERE username = $1') + .prepare( + 'SELECT COUNT(*) as count FROM search_history WHERE username = $1' + ) .bind(userName) .first(); const count = (countResult?.count as number) || 0; if (count > 20) { await this.db - .prepare(` + .prepare( + ` DELETE FROM search_history WHERE username = $1 AND id NOT IN ( @@ -1771,7 +2002,8 @@ export class PostgresStorage implements IStorage { ORDER BY timestamp DESC LIMIT 20 ) - `) + ` + ) .bind(userName) .run(); } @@ -1785,7 +2017,9 @@ export class PostgresStorage implements IStorage { try { if (keyword) { await this.db - .prepare('DELETE FROM search_history WHERE username = $1 AND keyword = $2') + .prepare( + 'DELETE FROM search_history WHERE username = $1 AND keyword = $2' + ) .bind(userName, keyword) .run(); } else { @@ -1802,7 +2036,10 @@ export class PostgresStorage implements IStorage { // ==================== 漫画书架 ==================== - async getMangaShelf(userName: string, key: string): Promise { + async getMangaShelf( + userName: string, + key: string + ): Promise { try { await this.schemaReady; const result = await this.db @@ -1826,11 +2063,13 @@ export class PostgresStorage implements IStorage { latestChapterId: (result.latest_chapter_id as string) || undefined, latestChapterName: (result.latest_chapter_name as string) || undefined, latestChapterCount: - result.latest_chapter_count === null || result.latest_chapter_count === undefined + result.latest_chapter_count === null || + result.latest_chapter_count === undefined ? undefined : Number(result.latest_chapter_count), unreadChapterCount: - result.unread_chapter_count === null || result.unread_chapter_count === undefined + result.unread_chapter_count === null || + result.unread_chapter_count === undefined ? undefined : Number(result.unread_chapter_count), }; @@ -1840,11 +2079,16 @@ export class PostgresStorage implements IStorage { } } - async setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise { + async setMangaShelf( + userName: string, + key: string, + item: MangaShelfItem + ): Promise { try { await this.schemaReady; await this.db - .prepare(` + .prepare( + ` INSERT INTO manga_shelf ( username, key, source_id, source_name, manga_id, title, cover, save_time, description, author, status, last_chapter_id, last_chapter_name, @@ -1867,7 +2111,8 @@ export class PostgresStorage implements IStorage { latest_chapter_name = EXCLUDED.latest_chapter_name, latest_chapter_count = EXCLUDED.latest_chapter_count, unread_chapter_count = EXCLUDED.unread_chapter_count - `) + ` + ) .bind( userName, key, @@ -1894,11 +2139,15 @@ export class PostgresStorage implements IStorage { } } - async getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }> { + async getAllMangaShelf( + userName: string + ): Promise<{ [key: string]: MangaShelfItem }> { try { await this.schemaReady; const results = await this.db - .prepare('SELECT * FROM manga_shelf WHERE username = $1 ORDER BY save_time DESC') + .prepare( + 'SELECT * FROM manga_shelf WHERE username = $1 ORDER BY save_time DESC' + ) .bind(userName) .all(); @@ -1921,11 +2170,13 @@ export class PostgresStorage implements IStorage { latestChapterId: (row.latest_chapter_id as string) || undefined, latestChapterName: (row.latest_chapter_name as string) || undefined, latestChapterCount: - row.latest_chapter_count === null || row.latest_chapter_count === undefined + row.latest_chapter_count === null || + row.latest_chapter_count === undefined ? undefined : Number(row.latest_chapter_count), unreadChapterCount: - row.unread_chapter_count === null || row.unread_chapter_count === undefined + row.unread_chapter_count === null || + row.unread_chapter_count === undefined ? undefined : Number(row.unread_chapter_count), }; @@ -1952,10 +2203,15 @@ export class PostgresStorage implements IStorage { // ==================== 漫画阅读历史 ==================== - async getMangaReadRecord(userName: string, key: string): Promise { + async getMangaReadRecord( + userName: string, + key: string + ): Promise { try { const result = await this.db - .prepare('SELECT * FROM manga_read_records WHERE username = $1 AND key = $2') + .prepare( + 'SELECT * FROM manga_read_records WHERE username = $1 AND key = $2' + ) .bind(userName, key) .first(); @@ -1978,10 +2234,15 @@ export class PostgresStorage implements IStorage { } } - async setMangaReadRecord(userName: string, key: string, record: MangaReadRecord): Promise { + async setMangaReadRecord( + userName: string, + key: string, + record: MangaReadRecord + ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO manga_read_records ( username, key, source_id, source_name, manga_id, title, cover, chapter_id, chapter_name, page_index, page_count, save_time @@ -1998,7 +2259,8 @@ export class PostgresStorage implements IStorage { page_index = EXCLUDED.page_index, page_count = EXCLUDED.page_count, save_time = EXCLUDED.save_time - `) + ` + ) .bind( userName, key, @@ -2020,10 +2282,14 @@ export class PostgresStorage implements IStorage { } } - async getAllMangaReadRecords(userName: string): Promise<{ [key: string]: MangaReadRecord }> { + async getAllMangaReadRecords( + userName: string + ): Promise<{ [key: string]: MangaReadRecord }> { try { const results = await this.db - .prepare('SELECT * FROM manga_read_records WHERE username = $1 ORDER BY save_time DESC') + .prepare( + 'SELECT * FROM manga_read_records WHERE username = $1 ORDER BY save_time DESC' + ) .bind(userName) .all(); @@ -2055,7 +2321,9 @@ export class PostgresStorage implements IStorage { async deleteMangaReadRecord(userName: string, key: string): Promise { try { await this.db - .prepare('DELETE FROM manga_read_records WHERE username = $1 AND key = $2') + .prepare( + 'DELETE FROM manga_read_records WHERE username = $1 AND key = $2' + ) .bind(userName, key) .run(); } catch (err) { @@ -2066,10 +2334,15 @@ export class PostgresStorage implements IStorage { async cleanupOldMangaReadRecords(userName: string): Promise { try { - const maxRecords = parseInt(process.env.MAX_MANGA_HISTORY_PER_USER || '100', 10); + const maxRecords = parseInt( + process.env.MAX_MANGA_HISTORY_PER_USER || '100', + 10 + ); const threshold = maxRecords + 10; const countResult = await this.db - .prepare('SELECT COUNT(*) as count FROM manga_read_records WHERE username = $1') + .prepare( + 'SELECT COUNT(*) as count FROM manga_read_records WHERE username = $1' + ) .bind(userName) .first(); @@ -2077,7 +2350,8 @@ export class PostgresStorage implements IStorage { if (count <= threshold) return; await this.db - .prepare(` + .prepare( + ` DELETE FROM manga_read_records WHERE username = $1 AND key NOT IN ( @@ -2086,7 +2360,8 @@ export class PostgresStorage implements IStorage { ORDER BY save_time DESC LIMIT $2 ) - `) + ` + ) .bind(userName, maxRecords) .run(); } catch (err) { @@ -2095,10 +2370,12 @@ export class PostgresStorage implements IStorage { } } - // ==================== 电子书书架 ==================== - async getBookShelf(userName: string, key: string): Promise { + async getBookShelf( + userName: string, + key: string + ): Promise { try { const result = await this.db .prepare('SELECT * FROM book_shelf WHERE username = $1 AND key = $2') @@ -2116,9 +2393,18 @@ export class PostgresStorage implements IStorage { format: (result.format as 'epub' | 'pdf' | null) || undefined, detailHref: (result.detail_href as string) || undefined, acquisitionHref: (result.acquisition_href as string) || undefined, - progressPercent: result.progress_percent === null || result.progress_percent === undefined ? undefined : Number(result.progress_percent), - lastReadTime: result.last_read_time === null || result.last_read_time === undefined ? undefined : Number(result.last_read_time), - lastLocatorType: (result.last_locator_type as BookShelfItem['lastLocatorType']) || undefined, + progressPercent: + result.progress_percent === null || + result.progress_percent === undefined + ? undefined + : Number(result.progress_percent), + lastReadTime: + result.last_read_time === null || result.last_read_time === undefined + ? undefined + : Number(result.last_read_time), + lastLocatorType: + (result.last_locator_type as BookShelfItem['lastLocatorType']) || + undefined, lastLocatorValue: (result.last_locator_value as string) || undefined, lastChapterTitle: (result.last_chapter_title as string) || undefined, saveTime: Number(result.save_time || 0), @@ -2129,10 +2415,15 @@ export class PostgresStorage implements IStorage { } } - async setBookShelf(userName: string, key: string, item: BookShelfItem): Promise { + async setBookShelf( + userName: string, + key: string, + item: BookShelfItem + ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO book_shelf ( username, key, source_id, source_name, book_id, title, author, cover, format, detail_href, acquisition_href, progress_percent, last_read_time, last_locator_type, last_locator_value, last_chapter_title, save_time @@ -2154,12 +2445,26 @@ export class PostgresStorage implements IStorage { last_locator_value = EXCLUDED.last_locator_value, last_chapter_title = EXCLUDED.last_chapter_title, save_time = EXCLUDED.save_time - `) + ` + ) .bind( - userName, key, item.sourceId, item.sourceName, item.bookId, item.title, item.author || null, - item.cover || null, item.format || null, item.detailHref || null, item.acquisitionHref || null, item.progressPercent ?? null, - item.lastReadTime ?? null, item.lastLocatorType || null, item.lastLocatorValue || null, - item.lastChapterTitle || null, item.saveTime + userName, + key, + item.sourceId, + item.sourceName, + item.bookId, + item.title, + item.author || null, + item.cover || null, + item.format || null, + item.detailHref || null, + item.acquisitionHref || null, + item.progressPercent ?? null, + item.lastReadTime ?? null, + item.lastLocatorType || null, + item.lastLocatorValue || null, + item.lastChapterTitle || null, + item.saveTime ) .run(); } catch (err) { @@ -2168,10 +2473,14 @@ export class PostgresStorage implements IStorage { } } - async getAllBookShelf(userName: string): Promise<{ [key: string]: BookShelfItem }> { + async getAllBookShelf( + userName: string + ): Promise<{ [key: string]: BookShelfItem }> { try { const results = await this.db - .prepare('SELECT * FROM book_shelf WHERE username = $1 ORDER BY COALESCE(last_read_time, save_time) DESC') + .prepare( + 'SELECT * FROM book_shelf WHERE username = $1 ORDER BY COALESCE(last_read_time, save_time) DESC' + ) .bind(userName) .all(); const shelves: { [key: string]: BookShelfItem } = {}; @@ -2187,9 +2496,17 @@ export class PostgresStorage implements IStorage { format: (row.format as 'epub' | 'pdf' | null) || undefined, detailHref: (row.detail_href as string) || undefined, acquisitionHref: (row.acquisition_href as string) || undefined, - progressPercent: row.progress_percent === null || row.progress_percent === undefined ? undefined : Number(row.progress_percent), - lastReadTime: row.last_read_time === null || row.last_read_time === undefined ? undefined : Number(row.last_read_time), - lastLocatorType: (row.last_locator_type as BookShelfItem['lastLocatorType']) || undefined, + progressPercent: + row.progress_percent === null || row.progress_percent === undefined + ? undefined + : Number(row.progress_percent), + lastReadTime: + row.last_read_time === null || row.last_read_time === undefined + ? undefined + : Number(row.last_read_time), + lastLocatorType: + (row.last_locator_type as BookShelfItem['lastLocatorType']) || + undefined, lastLocatorValue: (row.last_locator_value as string) || undefined, lastChapterTitle: (row.last_chapter_title as string) || undefined, saveTime: Number(row.save_time || 0), @@ -2204,7 +2521,10 @@ export class PostgresStorage implements IStorage { async deleteBookShelf(userName: string, key: string): Promise { try { - await this.db.prepare('DELETE FROM book_shelf WHERE username = $1 AND key = $2').bind(userName, key).run(); + await this.db + .prepare('DELETE FROM book_shelf WHERE username = $1 AND key = $2') + .bind(userName, key) + .run(); } catch (err) { console.error('PostgresStorage.deleteBookShelf error:', err); throw err; @@ -2213,10 +2533,15 @@ export class PostgresStorage implements IStorage { // ==================== 电子书阅读历史 ==================== - async getBookReadRecord(userName: string, key: string): Promise { + async getBookReadRecord( + userName: string, + key: string + ): Promise { try { const result = await this.db - .prepare('SELECT * FROM book_read_records WHERE username = $1 AND key = $2') + .prepare( + 'SELECT * FROM book_read_records WHERE username = $1 AND key = $2' + ) .bind(userName, key) .first(); if (!result) return null; @@ -2247,10 +2572,15 @@ export class PostgresStorage implements IStorage { } } - async setBookReadRecord(userName: string, key: string, record: BookReadRecord): Promise { + async setBookReadRecord( + userName: string, + key: string, + record: BookReadRecord + ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO book_read_records ( username, key, source_id, source_name, book_id, title, author, cover, format, detail_href, acquisition_href, locator_type, locator_value, chapter_title, chapter_href, progress_percent, save_time @@ -2272,12 +2602,26 @@ export class PostgresStorage implements IStorage { chapter_href = EXCLUDED.chapter_href, progress_percent = EXCLUDED.progress_percent, save_time = EXCLUDED.save_time - `) + ` + ) .bind( - userName, key, record.sourceId, record.sourceName, record.bookId, record.title, record.author || null, - record.cover || null, record.format, record.detailHref || null, record.acquisitionHref || null, record.locator.type, record.locator.value, - record.chapterTitle || record.locator.chapterTitle || null, record.chapterHref || record.locator.href || null, - record.progressPercent, record.saveTime + userName, + key, + record.sourceId, + record.sourceName, + record.bookId, + record.title, + record.author || null, + record.cover || null, + record.format, + record.detailHref || null, + record.acquisitionHref || null, + record.locator.type, + record.locator.value, + record.chapterTitle || record.locator.chapterTitle || null, + record.chapterHref || record.locator.href || null, + record.progressPercent, + record.saveTime ) .run(); } catch (err) { @@ -2286,10 +2630,14 @@ export class PostgresStorage implements IStorage { } } - async getAllBookReadRecords(userName: string): Promise<{ [key: string]: BookReadRecord }> { + async getAllBookReadRecords( + userName: string + ): Promise<{ [key: string]: BookReadRecord }> { try { const results = await this.db - .prepare('SELECT * FROM book_read_records WHERE username = $1 ORDER BY save_time DESC') + .prepare( + 'SELECT * FROM book_read_records WHERE username = $1 ORDER BY save_time DESC' + ) .bind(userName) .all(); const records: { [key: string]: BookReadRecord } = {}; @@ -2326,7 +2674,12 @@ export class PostgresStorage implements IStorage { async deleteBookReadRecord(userName: string, key: string): Promise { try { - await this.db.prepare('DELETE FROM book_read_records WHERE username = $1 AND key = $2').bind(userName, key).run(); + await this.db + .prepare( + 'DELETE FROM book_read_records WHERE username = $1 AND key = $2' + ) + .bind(userName, key) + .run(); } catch (err) { console.error('PostgresStorage.deleteBookReadRecord error:', err); throw err; @@ -2335,16 +2688,22 @@ export class PostgresStorage implements IStorage { async cleanupOldBookReadRecords(userName: string): Promise { try { - const maxRecords = parseInt(process.env.MAX_BOOK_HISTORY_PER_USER || '100', 10); + const maxRecords = parseInt( + process.env.MAX_BOOK_HISTORY_PER_USER || '100', + 10 + ); const threshold = maxRecords + 10; const countResult = await this.db - .prepare('SELECT COUNT(*) as count FROM book_read_records WHERE username = $1') + .prepare( + 'SELECT COUNT(*) as count FROM book_read_records WHERE username = $1' + ) .bind(userName) .first(); const count = Number(countResult?.count || 0); if (count <= threshold) return; await this.db - .prepare(` + .prepare( + ` DELETE FROM book_read_records WHERE username = $1 AND key NOT IN ( @@ -2353,7 +2712,8 @@ export class PostgresStorage implements IStorage { ORDER BY save_time DESC LIMIT $2 ) - `) + ` + ) .bind(userName, maxRecords) .run(); } catch (err) { @@ -2364,7 +2724,11 @@ export class PostgresStorage implements IStorage { // ==================== 跳过配置 ==================== - async getSkipConfig(userName: string, source: string, id: string): Promise { + async getSkipConfig( + userName: string, + source: string, + id: string + ): Promise { try { const key = `${source}+${id}`; const result = await this.db @@ -2384,19 +2748,32 @@ export class PostgresStorage implements IStorage { } } - async setSkipConfig(userName: string, source: string, id: string, config: SkipConfig): Promise { + async setSkipConfig( + userName: string, + source: string, + id: string, + config: SkipConfig + ): Promise { try { const key = `${source}+${id}`; await this.db - .prepare(` + .prepare( + ` INSERT INTO skip_configs (username, key, enable, intro_time, outro_time) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (username, key) DO UPDATE SET enable = EXCLUDED.enable, intro_time = EXCLUDED.intro_time, outro_time = EXCLUDED.outro_time - `) - .bind(userName, key, config.enable ? 1 : 0, config.intro_time, config.outro_time) + ` + ) + .bind( + userName, + key, + config.enable ? 1 : 0, + config.intro_time, + config.outro_time + ) .run(); } catch (err) { console.error('PostgresStorage.setSkipConfig error:', err); @@ -2404,7 +2781,11 @@ export class PostgresStorage implements IStorage { } } - async deleteSkipConfig(userName: string, source: string, id: string): Promise { + async deleteSkipConfig( + userName: string, + source: string, + id: string + ): Promise { try { const key = `${source}+${id}`; await this.db @@ -2417,7 +2798,9 @@ export class PostgresStorage implements IStorage { } } - async getAllSkipConfigs(userName: string): Promise<{ [key: string]: SkipConfig }> { + async getAllSkipConfigs( + userName: string + ): Promise<{ [key: string]: SkipConfig }> { try { const results = await this.db .prepare('SELECT * FROM skip_configs WHERE username = $1') @@ -2454,7 +2837,9 @@ export class PostgresStorage implements IStorage { // ==================== 弹幕过滤配置 ==================== - async getDanmakuFilterConfig(userName: string): Promise { + async getDanmakuFilterConfig( + userName: string + ): Promise { try { const result = await this.db .prepare('SELECT rules FROM danmaku_filter_configs WHERE username = $1') @@ -2469,14 +2854,19 @@ export class PostgresStorage implements IStorage { } } - async setDanmakuFilterConfig(userName: string, config: DanmakuFilterConfig): Promise { + async setDanmakuFilterConfig( + userName: string, + config: DanmakuFilterConfig + ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO danmaku_filter_configs (username, rules) VALUES ($1, $2) ON CONFLICT (username) DO UPDATE SET rules = EXCLUDED.rules - `) + ` + ) .bind(userName, JSON.stringify(config)) .run(); } catch (err) { @@ -2502,7 +2892,9 @@ export class PostgresStorage implements IStorage { async getNotifications(userName: string): Promise { try { const results = await this.db - .prepare('SELECT * FROM notifications WHERE username = $1 ORDER BY timestamp DESC') + .prepare( + 'SELECT * FROM notifications WHERE username = $1 ORDER BY timestamp DESC' + ) .bind(userName) .all(); @@ -2522,13 +2914,18 @@ export class PostgresStorage implements IStorage { } } - async addNotification(userName: string, notification: Notification): Promise { + async addNotification( + userName: string, + notification: Notification + ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO notifications (id, username, type, title, message, timestamp, read, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - `) + ` + ) .bind( notification.id, userName, @@ -2546,10 +2943,15 @@ export class PostgresStorage implements IStorage { } } - async markNotificationAsRead(userName: string, notificationId: string): Promise { + async markNotificationAsRead( + userName: string, + notificationId: string + ): Promise { try { await this.db - .prepare('UPDATE notifications SET read = 1 WHERE username = $1 AND id = $2') + .prepare( + 'UPDATE notifications SET read = 1 WHERE username = $1 AND id = $2' + ) .bind(userName, notificationId) .run(); } catch (err) { @@ -2558,7 +2960,10 @@ export class PostgresStorage implements IStorage { } } - async deleteNotification(userName: string, notificationId: string): Promise { + async deleteNotification( + userName: string, + notificationId: string + ): Promise { try { await this.db .prepare('DELETE FROM notifications WHERE username = $1 AND id = $2') @@ -2585,7 +2990,9 @@ export class PostgresStorage implements IStorage { async getUnreadNotificationCount(userName: string): Promise { try { const result = await this.db - .prepare('SELECT COUNT(*) as count FROM notifications WHERE username = $1 AND read = 0') + .prepare( + 'SELECT COUNT(*) as count FROM notifications WHERE username = $1 AND read = 0' + ) .bind(userName) .first(); @@ -2630,14 +3037,16 @@ export class PostgresStorage implements IStorage { async createMovieRequest(request: MovieRequest): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO movie_requests ( id, tmdb_id, title, year, media_type, season, poster, overview, requested_by, request_count, status, created_at, updated_at, fulfilled_at, fulfilled_source, fulfilled_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) - `) + ` + ) .bind( request.id, request.tmdbId || null, @@ -2663,7 +3072,10 @@ export class PostgresStorage implements IStorage { } } - async updateMovieRequest(requestId: string, updates: Partial): Promise { + async updateMovieRequest( + requestId: string, + updates: Partial + ): Promise { try { const fields: string[] = []; const values: any[] = []; @@ -2700,7 +3112,11 @@ export class PostgresStorage implements IStorage { values.push(requestId); await this.db - .prepare(`UPDATE movie_requests SET ${fields.join(', ')} WHERE id = $${paramIndex}`) + .prepare( + `UPDATE movie_requests SET ${fields.join( + ', ' + )} WHERE id = $${paramIndex}` + ) .bind(...values) .run(); } catch (err) { @@ -2724,7 +3140,9 @@ export class PostgresStorage implements IStorage { async getUserMovieRequests(userName: string): Promise { try { const results = await this.db - .prepare('SELECT request_id FROM user_movie_requests WHERE username = $1') + .prepare( + 'SELECT request_id FROM user_movie_requests WHERE username = $1' + ) .bind(userName) .all(); @@ -2736,10 +3154,15 @@ export class PostgresStorage implements IStorage { } } - async addUserMovieRequest(userName: string, requestId: string): Promise { + async addUserMovieRequest( + userName: string, + requestId: string + ): Promise { try { await this.db - .prepare('INSERT INTO user_movie_requests (username, request_id) VALUES ($1, $2) ON CONFLICT (username, request_id) DO NOTHING') + .prepare( + 'INSERT INTO user_movie_requests (username, request_id) VALUES ($1, $2) ON CONFLICT (username, request_id) DO NOTHING' + ) .bind(userName, requestId) .run(); } catch (err) { @@ -2748,10 +3171,15 @@ export class PostgresStorage implements IStorage { } } - async removeUserMovieRequest(userName: string, requestId: string): Promise { + async removeUserMovieRequest( + userName: string, + requestId: string + ): Promise { try { await this.db - .prepare('DELETE FROM user_movie_requests WHERE username = $1 AND request_id = $2') + .prepare( + 'DELETE FROM user_movie_requests WHERE username = $1 AND request_id = $2' + ) .bind(userName, requestId) .run(); } catch (err) { @@ -2800,11 +3228,13 @@ export class PostgresStorage implements IStorage { async setAdminConfig(config: AdminConfig): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO admin_config (id, config, updated_at) VALUES (1, $1, $2) ON CONFLICT (id) DO UPDATE SET config = EXCLUDED.config, updated_at = EXCLUDED.updated_at - `) + ` + ) .bind(JSON.stringify(config), Date.now()) .run(); } catch (err) { @@ -2841,8 +3271,15 @@ export class PostgresStorage implements IStorage { await this.db.prepare(`DELETE FROM ${table}`).run(); } catch (err) { const message = err instanceof Error ? err.message : String(err); - if (message.includes('no such table') || message.includes('does not exist')) { - console.warn('PostgresStorage.clearAllData warning:', table, message); + if ( + message.includes('no such table') || + message.includes('does not exist') + ) { + console.warn( + 'PostgresStorage.clearAllData warning:', + table, + message + ); continue; } throw err; @@ -2871,11 +3308,13 @@ export class PostgresStorage implements IStorage { async setGlobalValue(key: string, value: string): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO global_config (key, value, updated_at) VALUES ($1, $2, $3) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at - `) + ` + ) .bind(key, value, Date.now()) .run(); } catch (err) { @@ -2899,7 +3338,9 @@ export class PostgresStorage implements IStorage { async getLastFavoriteCheckTime(userName: string): Promise { try { const result = await this.db - .prepare('SELECT last_check_time FROM favorite_check_times WHERE username = $1') + .prepare( + 'SELECT last_check_time FROM favorite_check_times WHERE username = $1' + ) .bind(userName) .first(); @@ -2910,14 +3351,19 @@ export class PostgresStorage implements IStorage { } } - async setLastFavoriteCheckTime(userName: string, timestamp: number): Promise { + async setLastFavoriteCheckTime( + userName: string, + timestamp: number + ): Promise { try { await this.db - .prepare(` + .prepare( + ` INSERT INTO favorite_check_times (username, last_check_time) VALUES ($1, $2) ON CONFLICT (username) DO UPDATE SET last_check_time = EXCLUDED.last_check_time - `) + ` + ) .bind(userName, timestamp) .run(); } catch (err) { @@ -2926,10 +3372,15 @@ export class PostgresStorage implements IStorage { } } - async updateLastMovieRequestTime(userName: string, timestamp: number): Promise { + async updateLastMovieRequestTime( + userName: string, + timestamp: number + ): Promise { try { await this.db - .prepare('UPDATE users SET last_movie_request_time = $1 WHERE username = $2') + .prepare( + 'UPDATE users SET last_movie_request_time = $1 WHERE username = $2' + ) .bind(timestamp, userName) .run(); } catch (err) { @@ -2952,11 +3403,13 @@ class PostgresRedisHashAdapter { async hSet(hashKey: string, field: string, value: string): Promise { const key = `${hashKey}:${field}`; await this.db - .prepare(` + .prepare( + ` INSERT INTO global_config (key, value, updated_at) VALUES ($1, $2, $3) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at - `) + ` + ) .bind(key, value, Date.now()) .run(); } diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index cc924b1..33dd528 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -5,7 +5,11 @@ import { createClient, RedisClientType } from 'redis'; import { AdminConfig } from './admin.types'; import { MangaReadRecord, MangaShelfItem } from './manga.types'; import { BookReadRecord, BookShelfItem } from './book.types'; -import { MusicV2HistoryRecord, MusicV2PlaylistItem, MusicV2PlaylistRecord } from './music-v2'; +import { + MusicV2HistoryRecord, + MusicV2PlaylistItem, + MusicV2PlaylistRecord, +} from './music-v2'; import { RedisAdapter } from './redis-adapter'; import { Favorite, IStorage, PlayRecord, SkipConfig } from './types'; import { userInfoCache } from './user-cache'; @@ -32,7 +36,10 @@ export interface RedisConnectionConfig { } // 添加Redis操作重试包装器 -export function createRetryWrapper(clientName: string, getClient: () => RedisClientType) { +export function createRetryWrapper( + clientName: string, + getClient: () => RedisClientType +) { return async function withRetry( operation: () => Promise, maxRetries = 3 @@ -51,7 +58,9 @@ export function createRetryWrapper(clientName: string, getClient: () => RedisCli if (isConnectionError && !isLastAttempt) { console.log( - `${clientName} operation failed, retrying... (${i + 1}/${maxRetries})` + `${clientName} operation failed, retrying... (${ + i + 1 + }/${maxRetries})` ); console.error('Error:', err.message); @@ -80,7 +89,10 @@ export function createRetryWrapper(clientName: string, getClient: () => RedisCli } // 创建客户端的工厂函数 -export function createRedisClient(config: RedisConnectionConfig, globalSymbol: symbol): RedisClientType { +export function createRedisClient( + config: RedisConnectionConfig, + globalSymbol: symbol +): RedisClientType { let client: RedisClientType | undefined = (global as any)[globalSymbol]; if (!client) { @@ -94,9 +106,13 @@ export function createRedisClient(config: RedisConnectionConfig, globalSymbol: s socket: { // 重连策略:指数退避,最大30秒 reconnectStrategy: (retries: number) => { - console.log(`${config.clientName} reconnection attempt ${retries + 1}`); + console.log( + `${config.clientName} reconnection attempt ${retries + 1}` + ); if (retries > 10) { - console.error(`${config.clientName} max reconnection attempts exceeded`); + console.error( + `${config.clientName} max reconnection attempts exceeded` + ); return false; // 停止重连 } return Math.min(1000 * Math.pow(2, retries), 30000); // 指数退避,最大30秒 @@ -151,11 +167,20 @@ export function createRedisClient(config: RedisConnectionConfig, globalSymbol: s // 抽象基类,包含所有通用的Redis操作逻辑 export abstract class BaseRedisStorage implements IStorage { protected adapter: RedisAdapter; - protected withRetry: (operation: () => Promise, maxRetries?: number) => Promise; + protected withRetry: ( + operation: () => Promise, + maxRetries?: number + ) => Promise; // 保留 client 属性用于向后兼容(数据迁移代码使用) client: any; - constructor(adapter: RedisAdapter, withRetryFn: (operation: () => Promise, maxRetries?: number) => Promise) { + constructor( + adapter: RedisAdapter, + withRetryFn: ( + operation: () => Promise, + maxRetries?: number + ) => Promise + ) { this.adapter = adapter; this.withRetry = withRetryFn; // 创建兼容层,同时支持驼峰和小写命名(用于数据迁移代码) @@ -176,8 +201,10 @@ export abstract class BaseRedisStorage implements IStorage { hget: (key: string, field: string) => this.adapter.hGet(key, field), hGetAll: (key: string) => this.adapter.hGetAll(key), hgetall: (key: string) => this.adapter.hGetAll(key), - zAdd: (key: string, member: { score: number; value: string }) => this.adapter.zAdd(key, member), - zadd: (key: string, member: { score: number; value: string }) => this.adapter.zAdd(key, member), + zAdd: (key: string, member: { score: number; value: string }) => + this.adapter.zAdd(key, member), + zadd: (key: string, member: { score: number; value: string }) => + this.adapter.zAdd(key, member), set: (key: string, value: string) => this.adapter.set(key, value), get: (key: string) => this.adapter.get(key), del: (...keys: string[]) => this.adapter.del(keys), @@ -231,7 +258,17 @@ export abstract class BaseRedisStorage implements IStorage { } async deletePlayRecord(userName: string, key: string): Promise { - await this.withRetry(() => this.adapter.hDel(this.prHashKey(userName), key)); + await this.withRetry(() => + this.adapter.hDel(this.prHashKey(userName), key) + ); + } + + async deletePlayRecords(userName: string, keys: string[]): Promise { + const uniqueKeys = Array.from(new Set(keys)).filter(Boolean); + if (uniqueKeys.length === 0) return; + await this.withRetry(() => + this.adapter.hDel(this.prHashKey(userName), ...uniqueKeys) + ); } // 清理超出限制的旧播放记录 @@ -260,7 +297,10 @@ export abstract class BaseRedisStorage implements IStorage { private async doCleanup(userName: string): Promise { try { // 获取配置的最大播放记录数,默认100 - const maxRecords = parseInt(process.env.MAX_PLAY_RECORDS_PER_USER || '100', 10); + const maxRecords = parseInt( + process.env.MAX_PLAY_RECORDS_PER_USER || '100', + 10 + ); const threshold = maxRecords + 10; // 超过最大值+10时才触发清理 // 获取所有播放记录 @@ -272,7 +312,9 @@ export abstract class BaseRedisStorage implements IStorage { return; } - console.log(`用户 ${userName} 的播放记录数 ${recordCount} 超过阈值 ${threshold},开始清理...`); + console.log( + `用户 ${userName} 的播放记录数 ${recordCount} 超过阈值 ${threshold},开始清理...` + ); // 将记录转换为数组并按 save_time 排序(从旧到新) const sortedRecords = Object.entries(allRecords).sort( @@ -330,13 +372,19 @@ export abstract class BaseRedisStorage implements IStorage { // 2. 获取旧结构的所有播放记录key const pattern = `u:${userName}:pr:*`; - const oldKeys: string[] = await this.withRetry(() => this.adapter.keys(pattern)); + const oldKeys: string[] = await this.withRetry(() => + this.adapter.keys(pattern) + ); if (oldKeys.length === 0) { console.log(`用户 ${userName} 没有旧的播放记录,标记为已迁移`); // 即使没有数据也标记为已迁移 await this.withRetry(() => - this.adapter.hSet(this.userInfoKey(userName), 'playrecord_migrated', 'true') + this.adapter.hSet( + this.userInfoKey(userName), + 'playrecord_migrated', + 'true' + ) ); // 清除用户信息缓存 const { userInfoCache } = await import('./user-cache'); @@ -365,7 +413,9 @@ export abstract class BaseRedisStorage implements IStorage { await this.withRetry(() => this.adapter.hSet(this.prHashKey(userName), hashData) ); - console.log(`成功迁移 ${Object.keys(hashData).length} 条播放记录到hash结构`); + console.log( + `成功迁移 ${Object.keys(hashData).length} 条播放记录到hash结构` + ); } // 6. 删除旧的key @@ -374,7 +424,11 @@ export abstract class BaseRedisStorage implements IStorage { // 7. 标记迁移完成 await this.withRetry(() => - this.adapter.hSet(this.userInfoKey(userName), 'playrecord_migrated', 'true') + this.adapter.hSet( + this.userInfoKey(userName), + 'playrecord_migrated', + 'true' + ) ); // 8. 清除用户信息缓存,确保下次获取时能读取到最新的迁移标识 @@ -407,7 +461,11 @@ export abstract class BaseRedisStorage implements IStorage { favorite: Favorite ): Promise { await this.withRetry(() => - this.adapter.hSet(this.favHashKey(userName), key, JSON.stringify(favorite)) + this.adapter.hSet( + this.favHashKey(userName), + key, + JSON.stringify(favorite) + ) ); } @@ -426,7 +484,9 @@ export abstract class BaseRedisStorage implements IStorage { } async deleteFavorite(userName: string, key: string): Promise { - await this.withRetry(() => this.adapter.hDel(this.favHashKey(userName), key)); + await this.withRetry(() => + this.adapter.hDel(this.favHashKey(userName), key) + ); } // 迁移收藏:从旧的多key结构迁移到新的hash结构 @@ -464,13 +524,19 @@ export abstract class BaseRedisStorage implements IStorage { // 2. 获取旧结构的所有收藏key const pattern = `u:${userName}:fav:*`; - const oldKeys: string[] = await this.withRetry(() => this.adapter.keys(pattern)); + const oldKeys: string[] = await this.withRetry(() => + this.adapter.keys(pattern) + ); if (oldKeys.length === 0) { console.log(`用户 ${userName} 没有旧的收藏,标记为已迁移`); // 即使没有数据也标记为已迁移 await this.withRetry(() => - this.adapter.hSet(this.userInfoKey(userName), 'favorite_migrated', 'true') + this.adapter.hSet( + this.userInfoKey(userName), + 'favorite_migrated', + 'true' + ) ); // 清除用户信息缓存 const { userInfoCache } = await import('./user-cache'); @@ -530,7 +596,11 @@ export abstract class BaseRedisStorage implements IStorage { return value ? JSON.parse(value) : null; } - async setMusicPlayRecord(userName: string, key: string, record: any): Promise { + async setMusicPlayRecord( + userName: string, + key: string, + record: any + ): Promise { await this.withRetry(() => this.adapter.hSet( this.musicPlayRecordHashKey(userName), @@ -540,7 +610,10 @@ export abstract class BaseRedisStorage implements IStorage { ); } - async batchSetMusicPlayRecords(userName: string, records: { key: string; record: any }[]): Promise { + async batchSetMusicPlayRecords( + userName: string, + records: { key: string; record: any }[] + ): Promise { if (records.length === 0) return; const hashKey = this.musicPlayRecordHashKey(userName); @@ -550,9 +623,7 @@ export abstract class BaseRedisStorage implements IStorage { data[key] = JSON.stringify(record); } - await this.withRetry(() => - this.adapter.hSet(hashKey, data) - ); + await this.withRetry(() => this.adapter.hSet(hashKey, data)); } async getAllMusicPlayRecords(userName: string): Promise> { @@ -594,12 +665,15 @@ export abstract class BaseRedisStorage implements IStorage { return `music_playlist:${playlistId}:songs`; } - async createMusicPlaylist(userName: string, playlist: { - id: string; - name: string; - description?: string; - cover?: string; - }): Promise { + async createMusicPlaylist( + userName: string, + playlist: { + id: string; + name: string; + description?: string; + cover?: string; + } + ): Promise { const now = Date.now(); const playlistData = { id: playlist.id, @@ -664,11 +738,14 @@ export abstract class BaseRedisStorage implements IStorage { return playlists.sort((a, b) => b.created_at - a.created_at); } - async updateMusicPlaylist(playlistId: string, updates: { - name?: string; - description?: string; - cover?: string; - }): Promise { + async updateMusicPlaylist( + playlistId: string, + updates: { + name?: string; + description?: string; + cover?: string; + } + ): Promise { const updateData: Record = { updated_at: Date.now().toString(), }; @@ -709,15 +786,18 @@ export abstract class BaseRedisStorage implements IStorage { ); } - async addSongToPlaylist(playlistId: string, song: { - platform: string; - id: string; - name: string; - artist: string; - album?: string; - pic?: string; - duration: number; - }): Promise { + async addSongToPlaylist( + playlistId: string, + song: { + platform: string; + id: string; + name: string; + artist: string; + album?: string; + pic?: string; + duration: number; + } + ): Promise { const now = Date.now(); const songKey = `${song.platform}+${song.id}`; @@ -734,7 +814,11 @@ export abstract class BaseRedisStorage implements IStorage { // 添加歌曲到歌单(使用 hash 存储歌曲信息) await this.withRetry(() => - this.adapter.hSet(this.musicPlaylistSongsKey(playlistId), songKey, JSON.stringify(songData)) + this.adapter.hSet( + this.musicPlaylistSongsKey(playlistId), + songKey, + JSON.stringify(songData) + ) ); // 更新歌单的 updated_at @@ -747,7 +831,11 @@ export abstract class BaseRedisStorage implements IStorage { } } - async removeSongFromPlaylist(playlistId: string, platform: string, songId: string): Promise { + async removeSongFromPlaylist( + playlistId: string, + platform: string, + songId: string + ): Promise { const songKey = `${platform}+${songId}`; await this.withRetry(() => @@ -786,7 +874,11 @@ export abstract class BaseRedisStorage implements IStorage { return songs.sort((a, b) => a.added_at - b.added_at); } - async isSongInPlaylist(playlistId: string, platform: string, songId: string): Promise { + async isSongInPlaylist( + playlistId: string, + platform: string, + songId: string + ): Promise { const songKey = `${platform}+${songId}`; const exists = await this.withRetry(() => this.adapter.hGet(this.musicPlaylistSongsKey(playlistId), songKey) @@ -804,39 +896,59 @@ export abstract class BaseRedisStorage implements IStorage { this.adapter.hGetAll(this.musicV2HistoryKey(userName)) ); - return Object.values(rows || {}) - .filter(Boolean) - .map(value => JSON.parse(value as string) as MusicV2HistoryRecord) - // 按队列顺序返回;当前播放项由最大 lastPlayedAt 决定。 - // createdAt 相同时使用歌曲标识做稳定兜底,避免最近播放时间把歌曲顶到队尾。 - .sort((a, b) => { - const createdAtDiff = (a.createdAt || 0) - (b.createdAt || 0); - if (createdAtDiff !== 0) return createdAtDiff; - return `${a.source}:${a.songId}`.localeCompare(`${b.source}:${b.songId}`); - }); - } - - async upsertMusicV2History(userName: string, record: MusicV2HistoryRecord): Promise { - await this.withRetry(() => - this.adapter.hSet(this.musicV2HistoryKey(userName), record.songId, JSON.stringify(record)) + return ( + Object.values(rows || {}) + .filter(Boolean) + .map((value) => JSON.parse(value as string) as MusicV2HistoryRecord) + // 按队列顺序返回;当前播放项由最大 lastPlayedAt 决定。 + // createdAt 相同时使用歌曲标识做稳定兜底,避免最近播放时间把歌曲顶到队尾。 + .sort((a, b) => { + const createdAtDiff = (a.createdAt || 0) - (b.createdAt || 0); + if (createdAtDiff !== 0) return createdAtDiff; + return `${a.source}:${a.songId}`.localeCompare( + `${b.source}:${b.songId}` + ); + }) ); } - async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise { + async upsertMusicV2History( + userName: string, + record: MusicV2HistoryRecord + ): Promise { + await this.withRetry(() => + this.adapter.hSet( + this.musicV2HistoryKey(userName), + record.songId, + JSON.stringify(record) + ) + ); + } + + async batchUpsertMusicV2History( + userName: string, + records: MusicV2HistoryRecord[] + ): Promise { if (!records.length) return; const payload: Record = {}; for (const record of records) { payload[record.songId] = JSON.stringify(record); } - await this.withRetry(() => this.adapter.hSet(this.musicV2HistoryKey(userName), payload)); + await this.withRetry(() => + this.adapter.hSet(this.musicV2HistoryKey(userName), payload) + ); } async deleteMusicV2History(userName: string, songId: string): Promise { - await this.withRetry(() => this.adapter.hDel(this.musicV2HistoryKey(userName), songId)); + await this.withRetry(() => + this.adapter.hDel(this.musicV2HistoryKey(userName), songId) + ); } async clearMusicV2History(userName: string): Promise { - await this.withRetry(() => this.adapter.del(this.musicV2HistoryKey(userName))); + await this.withRetry(() => + this.adapter.del(this.musicV2HistoryKey(userName)) + ); } // ---------- Music V2 歌单 ---------- @@ -852,12 +964,15 @@ export abstract class BaseRedisStorage implements IStorage { return `music:v2:playlist:${playlistId}:items`; } - async createMusicV2Playlist(userName: string, playlist: { - id: string; - name: string; - description?: string; - cover?: string; - }): Promise { + async createMusicV2Playlist( + userName: string, + playlist: { + id: string; + name: string; + description?: string; + cover?: string; + } + ): Promise { const now = Date.now(); const payload = { id: playlist.id, @@ -870,14 +985,23 @@ export abstract class BaseRedisStorage implements IStorage { updated_at: now.toString(), }; - await this.withRetry(() => this.adapter.hSet(this.musicV2PlaylistKey(playlist.id), payload)); await this.withRetry(() => - this.adapter.zAdd(this.musicV2PlaylistsKey(userName), { score: now, value: playlist.id }) + this.adapter.hSet(this.musicV2PlaylistKey(playlist.id), payload) + ); + await this.withRetry(() => + this.adapter.zAdd(this.musicV2PlaylistsKey(userName), { + score: now, + value: playlist.id, + }) ); } - async getMusicV2Playlist(playlistId: string): Promise { - const data = await this.withRetry(() => this.adapter.hGetAll(this.musicV2PlaylistKey(playlistId))); + async getMusicV2Playlist( + playlistId: string + ): Promise { + const data = await this.withRetry(() => + this.adapter.hGetAll(this.musicV2PlaylistKey(playlistId)) + ); if (!data || Object.keys(data).length === 0) return null; return { id: data.id, @@ -891,8 +1015,12 @@ export abstract class BaseRedisStorage implements IStorage { }; } - async listMusicV2Playlists(userName: string): Promise { - const playlistIds = await this.withRetry(() => this.adapter.zRange(this.musicV2PlaylistsKey(userName), 0, -1)); + async listMusicV2Playlists( + userName: string + ): Promise { + const playlistIds = await this.withRetry(() => + this.adapter.zRange(this.musicV2PlaylistsKey(userName), 0, -1) + ); const playlists: MusicV2PlaylistRecord[] = []; for (const playlistId of playlistIds || []) { const playlist = await this.getMusicV2Playlist(ensureString(playlistId)); @@ -901,33 +1029,53 @@ export abstract class BaseRedisStorage implements IStorage { return playlists.sort((a, b) => b.updated_at - a.updated_at); } - async updateMusicV2Playlist(playlistId: string, updates: { - name?: string; - description?: string; - cover?: string; - song_count?: number; - }): Promise { + async updateMusicV2Playlist( + playlistId: string, + updates: { + name?: string; + description?: string; + cover?: string; + song_count?: number; + } + ): Promise { const payload: Record = { updated_at: Date.now().toString(), }; if (updates.name !== undefined) payload.name = updates.name; - if (updates.description !== undefined) payload.description = updates.description || ''; + if (updates.description !== undefined) + payload.description = updates.description || ''; if (updates.cover !== undefined) payload.cover = updates.cover || ''; - if (updates.song_count !== undefined) payload.song_count = String(updates.song_count); - await this.withRetry(() => this.adapter.hSet(this.musicV2PlaylistKey(playlistId), payload)); + if (updates.song_count !== undefined) + payload.song_count = String(updates.song_count); + await this.withRetry(() => + this.adapter.hSet(this.musicV2PlaylistKey(playlistId), payload) + ); } async deleteMusicV2Playlist(playlistId: string): Promise { const playlist = await this.getMusicV2Playlist(playlistId); if (!playlist) return; - await this.withRetry(() => this.adapter.zRem(this.musicV2PlaylistsKey(playlist.username), playlistId)); - await this.withRetry(() => this.adapter.del(this.musicV2PlaylistKey(playlistId))); - await this.withRetry(() => this.adapter.del(this.musicV2PlaylistItemsKey(playlistId))); + await this.withRetry(() => + this.adapter.zRem(this.musicV2PlaylistsKey(playlist.username), playlistId) + ); + await this.withRetry(() => + this.adapter.del(this.musicV2PlaylistKey(playlistId)) + ); + await this.withRetry(() => + this.adapter.del(this.musicV2PlaylistItemsKey(playlistId)) + ); } - async addMusicV2PlaylistItem(playlistId: string, item: MusicV2PlaylistItem): Promise { + async addMusicV2PlaylistItem( + playlistId: string, + item: MusicV2PlaylistItem + ): Promise { await this.withRetry(() => - this.adapter.hSet(this.musicV2PlaylistItemsKey(playlistId), item.songId, JSON.stringify(item)) + this.adapter.hSet( + this.musicV2PlaylistItemsKey(playlistId), + item.songId, + JSON.stringify(item) + ) ); const items = await this.listMusicV2PlaylistItems(playlistId); const playlist = await this.getMusicV2Playlist(playlistId); @@ -937,8 +1085,13 @@ export abstract class BaseRedisStorage implements IStorage { }); } - async removeMusicV2PlaylistItem(playlistId: string, songId: string): Promise { - await this.withRetry(() => this.adapter.hDel(this.musicV2PlaylistItemsKey(playlistId), songId)); + async removeMusicV2PlaylistItem( + playlistId: string, + songId: string + ): Promise { + await this.withRetry(() => + this.adapter.hDel(this.musicV2PlaylistItemsKey(playlistId), songId) + ); const items = await this.listMusicV2PlaylistItems(playlistId); await this.updateMusicV2Playlist(playlistId, { song_count: items.length, @@ -946,16 +1099,25 @@ export abstract class BaseRedisStorage implements IStorage { }); } - async listMusicV2PlaylistItems(playlistId: string): Promise { - const rows = await this.withRetry(() => this.adapter.hGetAll(this.musicV2PlaylistItemsKey(playlistId))); + async listMusicV2PlaylistItems( + playlistId: string + ): Promise { + const rows = await this.withRetry(() => + this.adapter.hGetAll(this.musicV2PlaylistItemsKey(playlistId)) + ); return Object.values(rows || {}) .filter(Boolean) - .map(value => JSON.parse(value as string) as MusicV2PlaylistItem) + .map((value) => JSON.parse(value as string) as MusicV2PlaylistItem) .sort((a, b) => a.sortOrder - b.sortOrder || a.addedAt - b.addedAt); } - async hasMusicV2PlaylistItem(playlistId: string, songId: string): Promise { - const exists = await this.withRetry(() => this.adapter.hGet(this.musicV2PlaylistItemsKey(playlistId), songId)); + async hasMusicV2PlaylistItem( + playlistId: string, + songId: string + ): Promise { + const exists = await this.withRetry(() => + this.adapter.hGet(this.musicV2PlaylistItemsKey(playlistId), songId) + ); return exists !== null; } @@ -1014,8 +1176,12 @@ export abstract class BaseRedisStorage implements IStorage { await this.withRetry(() => this.adapter.del(this.favHashKey(userName))); // 删除漫画书架与历史 - await this.withRetry(() => this.adapter.del(this.mangaShelfHashKey(userName))); - await this.withRetry(() => this.adapter.del(this.mangaReadHashKey(userName))); + await this.withRetry(() => + this.adapter.del(this.mangaShelfHashKey(userName)) + ); + await this.withRetry(() => + this.adapter.del(this.mangaReadHashKey(userName)) + ); // 删除旧的收藏key(如果有) const favoritePattern = `u:${userName}:fav:*`; @@ -1039,7 +1205,9 @@ export abstract class BaseRedisStorage implements IStorage { } // 删除音乐播放记录 - await this.withRetry(() => this.adapter.del(this.musicPlayRecordHashKey(userName))); + await this.withRetry(() => + this.adapter.del(this.musicPlayRecordHashKey(userName)) + ); // 删除用户的所有歌单 const playlistIds = await this.withRetry(() => @@ -1051,14 +1219,20 @@ export abstract class BaseRedisStorage implements IStorage { // 删除歌单信息 await this.withRetry(() => this.adapter.del(this.musicPlaylistKey(id))); // 删除歌单的歌曲列表 - await this.withRetry(() => this.adapter.del(this.musicPlaylistSongsKey(id))); + await this.withRetry(() => + this.adapter.del(this.musicPlaylistSongsKey(id)) + ); } } // 删除用户的歌单列表 - await this.withRetry(() => this.adapter.del(this.musicPlaylistsKey(userName))); + await this.withRetry(() => + this.adapter.del(this.musicPlaylistsKey(userName)) + ); // 删除音乐 V2 播放记录 - await this.withRetry(() => this.adapter.del(this.musicV2HistoryKey(userName))); + await this.withRetry(() => + this.adapter.del(this.musicV2HistoryKey(userName)) + ); // 删除音乐 V2 歌单 const musicV2PlaylistIds = await this.withRetry(() => @@ -1067,11 +1241,17 @@ export abstract class BaseRedisStorage implements IStorage { if (musicV2PlaylistIds && musicV2PlaylistIds.length > 0) { for (const playlistId of musicV2PlaylistIds) { const id = ensureString(playlistId); - await this.withRetry(() => this.adapter.del(this.musicV2PlaylistKey(id))); - await this.withRetry(() => this.adapter.del(this.musicV2PlaylistItemsKey(id))); + await this.withRetry(() => + this.adapter.del(this.musicV2PlaylistKey(id)) + ); + await this.withRetry(() => + this.adapter.del(this.musicV2PlaylistItemsKey(id)) + ); } } - await this.withRetry(() => this.adapter.del(this.musicV2PlaylistsKey(userName))); + await this.withRetry(() => + this.adapter.del(this.musicV2PlaylistsKey(userName)) + ); } // ---------- 新版用户存储(使用Hash和Sorted Set) ---------- @@ -1093,7 +1273,7 @@ export abstract class BaseRedisStorage implements IStorage { const data = encoder.encode(password); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); } // 创建新用户(新版本) @@ -1127,16 +1307,22 @@ export abstract class BaseRedisStorage implements IStorage { if (oidcSub) { userInfo.oidcSub = oidcSub; // 创建OIDC映射 - await this.withRetry(() => this.adapter.set(this.oidcSubKey(oidcSub), userName)); + await this.withRetry(() => + this.adapter.set(this.oidcSubKey(oidcSub), userName) + ); } - await this.withRetry(() => this.adapter.hSet(this.userInfoKey(userName), userInfo)); + await this.withRetry(() => + this.adapter.hSet(this.userInfoKey(userName), userInfo) + ); // 添加到用户列表(Sorted Set,按注册时间排序) - await this.withRetry(() => this.adapter.zAdd(this.userListKey(), { - score: createdAt, - value: userName, - })); + await this.withRetry(() => + this.adapter.zAdd(this.userListKey(), { + score: createdAt, + value: userName, + }) + ); // 清除用户信息缓存 userInfoCache?.delete(userName); @@ -1211,13 +1397,17 @@ export abstract class BaseRedisStorage implements IStorage { skip_migrated: 'true', }; - await this.withRetry(() => this.adapter.hSet(this.userInfoKey(userName), userInfo)); + await this.withRetry(() => + this.adapter.hSet(this.userInfoKey(userName), userInfo) + ); // 添加到用户列表(Sorted Set,按注册时间排序) - await this.withRetry(() => this.adapter.zAdd(this.userListKey(), { - score: ownerInfo.created_at, - value: userName, - })); + await this.withRetry(() => + this.adapter.zAdd(this.userListKey(), { + score: ownerInfo.created_at, + value: userName, + }) + ); console.log(`Created database record for site owner: ${userName}`); } catch (insertErr) { @@ -1237,12 +1427,16 @@ export abstract class BaseRedisStorage implements IStorage { banned: userInfoRaw.banned === 'true', tags: userInfoRaw.tags ? JSON.parse(userInfoRaw.tags) : undefined, oidcSub: userInfoRaw.oidcSub, - enabledApis: userInfoRaw.enabledApis ? JSON.parse(userInfoRaw.enabledApis) : undefined, + enabledApis: userInfoRaw.enabledApis + ? JSON.parse(userInfoRaw.enabledApis) + : undefined, created_at: parseInt(userInfoRaw.created_at || '0', 10), playrecord_migrated: userInfoRaw.playrecord_migrated === 'true', favorite_migrated: userInfoRaw.favorite_migrated === 'true', skip_migrated: userInfoRaw.skip_migrated === 'true', - last_movie_request_time: userInfoRaw.last_movie_request_time ? parseInt(userInfoRaw.last_movie_request_time, 10) : undefined, + last_movie_request_time: userInfoRaw.last_movie_request_time + ? parseInt(userInfoRaw.last_movie_request_time, 10) + : undefined, email: userInfoRaw.email, emailNotifications: userInfoRaw.emailNotifications === 'true', }; @@ -1284,7 +1478,9 @@ export abstract class BaseRedisStorage implements IStorage { userInfo.tags = JSON.stringify(updates.tags); } else { // 删除tags字段 - await this.withRetry(() => this.adapter.hDel(this.userInfoKey(userName), 'tags')); + await this.withRetry(() => + this.adapter.hDel(this.userInfoKey(userName), 'tags') + ); } } @@ -1293,7 +1489,9 @@ export abstract class BaseRedisStorage implements IStorage { userInfo.enabledApis = JSON.stringify(updates.enabledApis); } else { // 删除enabledApis字段 - await this.withRetry(() => this.adapter.hDel(this.userInfoKey(userName), 'enabledApis')); + await this.withRetry(() => + this.adapter.hDel(this.userInfoKey(userName), 'enabledApis') + ); } } @@ -1301,15 +1499,21 @@ export abstract class BaseRedisStorage implements IStorage { const oldInfo = await this.getUserInfoV2(userName); if (oldInfo?.oidcSub && oldInfo.oidcSub !== updates.oidcSub) { // 删除旧的OIDC映射 - await this.withRetry(() => this.adapter.del(this.oidcSubKey(oldInfo.oidcSub!))); + await this.withRetry(() => + this.adapter.del(this.oidcSubKey(oldInfo.oidcSub!)) + ); } userInfo.oidcSub = updates.oidcSub; // 创建新的OIDC映射 - await this.withRetry(() => this.adapter.set(this.oidcSubKey(updates.oidcSub!), userName)); + await this.withRetry(() => + this.adapter.set(this.oidcSubKey(updates.oidcSub!), userName) + ); } if (Object.keys(userInfo).length > 0) { - await this.withRetry(() => this.adapter.hSet(this.userInfoKey(userName), userInfo)); + await this.withRetry(() => + this.adapter.hSet(this.userInfoKey(userName), userInfo) + ); } // 清除缓存 @@ -1364,7 +1568,9 @@ export abstract class BaseRedisStorage implements IStorage { const trimmedSearch = search?.trim() || ''; // 获取总数 - let total = await this.withRetry(() => this.adapter.zCard(this.userListKey())); + let total = await this.withRetry(() => + this.adapter.zCard(this.userListKey()) + ); // 检查站长是否在数据库中(使用缓存) let ownerInfo = null; @@ -1493,7 +1699,9 @@ export abstract class BaseRedisStorage implements IStorage { // 删除OIDC映射 if (userInfo?.oidcSub) { - await this.withRetry(() => this.adapter.del(this.oidcSubKey(userInfo.oidcSub!))); + await this.withRetry(() => + this.adapter.del(this.oidcSubKey(userInfo.oidcSub!)) + ); } // 删除用户信息Hash @@ -1525,17 +1733,23 @@ export abstract class BaseRedisStorage implements IStorage { async addSearchHistory(userName: string, keyword: string): Promise { const key = this.shKey(userName); // 先去重 - await this.withRetry(() => this.adapter.lRem(key, 0, ensureString(keyword))); + await this.withRetry(() => + this.adapter.lRem(key, 0, ensureString(keyword)) + ); // 插入到最前 await this.withRetry(() => this.adapter.lPush(key, ensureString(keyword))); // 限制最大长度 - await this.withRetry(() => this.adapter.lTrim(key, 0, SEARCH_HISTORY_LIMIT - 1)); + await this.withRetry(() => + this.adapter.lTrim(key, 0, SEARCH_HISTORY_LIMIT - 1) + ); } async deleteSearchHistory(userName: string, keyword?: string): Promise { const key = this.shKey(userName); if (keyword) { - await this.withRetry(() => this.adapter.lRem(key, 0, ensureString(keyword))); + await this.withRetry(() => + this.adapter.lRem(key, 0, ensureString(keyword)) + ); } else { await this.withRetry(() => this.adapter.del(key)); } @@ -1546,17 +1760,36 @@ export abstract class BaseRedisStorage implements IStorage { return `u:${user}:manga:shelf`; } - async getMangaShelf(userName: string, key: string): Promise { - const val = await this.withRetry(() => this.adapter.hGet(this.mangaShelfHashKey(userName), key)); + async getMangaShelf( + userName: string, + key: string + ): Promise { + const val = await this.withRetry(() => + this.adapter.hGet(this.mangaShelfHashKey(userName), key) + ); return val ? (JSON.parse(val) as MangaShelfItem) : null; } - async setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise { - await this.withRetry(() => this.adapter.hSet(this.mangaShelfHashKey(userName), key, JSON.stringify(item))); + async setMangaShelf( + userName: string, + key: string, + item: MangaShelfItem + ): Promise { + await this.withRetry(() => + this.adapter.hSet( + this.mangaShelfHashKey(userName), + key, + JSON.stringify(item) + ) + ); } - async getAllMangaShelf(userName: string): Promise> { - const hashData = await this.withRetry(() => this.adapter.hGetAll(this.mangaShelfHashKey(userName))); + async getAllMangaShelf( + userName: string + ): Promise> { + const hashData = await this.withRetry(() => + this.adapter.hGetAll(this.mangaShelfHashKey(userName)) + ); const result: Record = {}; for (const [key, value] of Object.entries(hashData)) { if (value) result[key] = JSON.parse(value) as MangaShelfItem; @@ -1565,7 +1798,9 @@ export abstract class BaseRedisStorage implements IStorage { } async deleteMangaShelf(userName: string, key: string): Promise { - await this.withRetry(() => this.adapter.hDel(this.mangaShelfHashKey(userName), key)); + await this.withRetry(() => + this.adapter.hDel(this.mangaShelfHashKey(userName), key) + ); } // ---------- 漫画阅读历史 ---------- @@ -1573,17 +1808,36 @@ export abstract class BaseRedisStorage implements IStorage { return `u:${user}:manga:history`; } - async getMangaReadRecord(userName: string, key: string): Promise { - const val = await this.withRetry(() => this.adapter.hGet(this.mangaReadHashKey(userName), key)); + async getMangaReadRecord( + userName: string, + key: string + ): Promise { + const val = await this.withRetry(() => + this.adapter.hGet(this.mangaReadHashKey(userName), key) + ); return val ? (JSON.parse(val) as MangaReadRecord) : null; } - async setMangaReadRecord(userName: string, key: string, record: MangaReadRecord): Promise { - await this.withRetry(() => this.adapter.hSet(this.mangaReadHashKey(userName), key, JSON.stringify(record))); + async setMangaReadRecord( + userName: string, + key: string, + record: MangaReadRecord + ): Promise { + await this.withRetry(() => + this.adapter.hSet( + this.mangaReadHashKey(userName), + key, + JSON.stringify(record) + ) + ); } - async getAllMangaReadRecords(userName: string): Promise> { - const hashData = await this.withRetry(() => this.adapter.hGetAll(this.mangaReadHashKey(userName))); + async getAllMangaReadRecords( + userName: string + ): Promise> { + const hashData = await this.withRetry(() => + this.adapter.hGetAll(this.mangaReadHashKey(userName)) + ); const result: Record = {}; for (const [key, value] of Object.entries(hashData)) { if (value) result[key] = JSON.parse(value) as MangaReadRecord; @@ -1592,12 +1846,17 @@ export abstract class BaseRedisStorage implements IStorage { } async deleteMangaReadRecord(userName: string, key: string): Promise { - await this.withRetry(() => this.adapter.hDel(this.mangaReadHashKey(userName), key)); + await this.withRetry(() => + this.adapter.hDel(this.mangaReadHashKey(userName), key) + ); } async cleanupOldMangaReadRecords(userName: string): Promise { const records = await this.getAllMangaReadRecords(userName); - const maxRecords = parseInt(process.env.MAX_MANGA_HISTORY_PER_USER || '100', 10); + const maxRecords = parseInt( + process.env.MAX_MANGA_HISTORY_PER_USER || '100', + 10 + ); const threshold = maxRecords + 10; if (Object.keys(records).length <= threshold) return; const keys = Object.entries(records) @@ -1606,27 +1865,47 @@ export abstract class BaseRedisStorage implements IStorage { .map(([key]) => key); if (keys.length > 0) { - await this.withRetry(() => this.adapter.hDel(this.mangaReadHashKey(userName), ...keys)); + await this.withRetry(() => + this.adapter.hDel(this.mangaReadHashKey(userName), ...keys) + ); } } - // ---------- 电子书书架 ---------- private bookShelfHashKey(user: string) { return `u:${user}:book:shelf`; } - async getBookShelf(userName: string, key: string): Promise { - const val = await this.withRetry(() => this.adapter.hGet(this.bookShelfHashKey(userName), key)); + async getBookShelf( + userName: string, + key: string + ): Promise { + const val = await this.withRetry(() => + this.adapter.hGet(this.bookShelfHashKey(userName), key) + ); return val ? (JSON.parse(val) as BookShelfItem) : null; } - async setBookShelf(userName: string, key: string, item: BookShelfItem): Promise { - await this.withRetry(() => this.adapter.hSet(this.bookShelfHashKey(userName), key, JSON.stringify(item))); + async setBookShelf( + userName: string, + key: string, + item: BookShelfItem + ): Promise { + await this.withRetry(() => + this.adapter.hSet( + this.bookShelfHashKey(userName), + key, + JSON.stringify(item) + ) + ); } - async getAllBookShelf(userName: string): Promise> { - const hashData = await this.withRetry(() => this.adapter.hGetAll(this.bookShelfHashKey(userName))); + async getAllBookShelf( + userName: string + ): Promise> { + const hashData = await this.withRetry(() => + this.adapter.hGetAll(this.bookShelfHashKey(userName)) + ); const result: Record = {}; for (const [key, value] of Object.entries(hashData)) { if (value) result[key] = JSON.parse(value) as BookShelfItem; @@ -1635,7 +1914,9 @@ export abstract class BaseRedisStorage implements IStorage { } async deleteBookShelf(userName: string, key: string): Promise { - await this.withRetry(() => this.adapter.hDel(this.bookShelfHashKey(userName), key)); + await this.withRetry(() => + this.adapter.hDel(this.bookShelfHashKey(userName), key) + ); } // ---------- 电子书阅读历史 ---------- @@ -1643,17 +1924,36 @@ export abstract class BaseRedisStorage implements IStorage { return `u:${user}:book:history`; } - async getBookReadRecord(userName: string, key: string): Promise { - const val = await this.withRetry(() => this.adapter.hGet(this.bookReadHashKey(userName), key)); + async getBookReadRecord( + userName: string, + key: string + ): Promise { + const val = await this.withRetry(() => + this.adapter.hGet(this.bookReadHashKey(userName), key) + ); return val ? (JSON.parse(val) as BookReadRecord) : null; } - async setBookReadRecord(userName: string, key: string, record: BookReadRecord): Promise { - await this.withRetry(() => this.adapter.hSet(this.bookReadHashKey(userName), key, JSON.stringify(record))); + async setBookReadRecord( + userName: string, + key: string, + record: BookReadRecord + ): Promise { + await this.withRetry(() => + this.adapter.hSet( + this.bookReadHashKey(userName), + key, + JSON.stringify(record) + ) + ); } - async getAllBookReadRecords(userName: string): Promise> { - const hashData = await this.withRetry(() => this.adapter.hGetAll(this.bookReadHashKey(userName))); + async getAllBookReadRecords( + userName: string + ): Promise> { + const hashData = await this.withRetry(() => + this.adapter.hGetAll(this.bookReadHashKey(userName)) + ); const result: Record = {}; for (const [key, value] of Object.entries(hashData)) { if (value) result[key] = JSON.parse(value) as BookReadRecord; @@ -1662,12 +1962,17 @@ export abstract class BaseRedisStorage implements IStorage { } async deleteBookReadRecord(userName: string, key: string): Promise { - await this.withRetry(() => this.adapter.hDel(this.bookReadHashKey(userName), key)); + await this.withRetry(() => + this.adapter.hDel(this.bookReadHashKey(userName), key) + ); } async cleanupOldBookReadRecords(userName: string): Promise { const records = await this.getAllBookReadRecords(userName); - const maxRecords = parseInt(process.env.MAX_BOOK_HISTORY_PER_USER || '100', 10); + const maxRecords = parseInt( + process.env.MAX_BOOK_HISTORY_PER_USER || '100', + 10 + ); const threshold = maxRecords + 10; if (Object.keys(records).length <= threshold) return; const keys = Object.entries(records) @@ -1676,7 +1981,9 @@ export abstract class BaseRedisStorage implements IStorage { .map(([key]) => key); if (keys.length > 0) { - await this.withRetry(() => this.adapter.hDel(this.bookReadHashKey(userName), ...keys)); + await this.withRetry(() => + this.adapter.hDel(this.bookReadHashKey(userName), ...keys) + ); } } @@ -1687,7 +1994,7 @@ export abstract class BaseRedisStorage implements IStorage { const users = await this.withRetry(() => this.adapter.zRange(userListKey, 0, -1) ); - const userList = users.map(u => ensureString(u)); + const userList = users.map((u) => ensureString(u)); // 确保站长在列表中(站长可能不在数据库中,使用环境变量认证) const ownerUsername = process.env.USERNAME; @@ -1704,7 +2011,9 @@ export abstract class BaseRedisStorage implements IStorage { } async getAdminConfig(): Promise { - const val = await this.withRetry(() => this.adapter.get(this.adminConfigKey())); + const val = await this.withRetry(() => + this.adapter.get(this.adminConfigKey()) + ); return val ? (JSON.parse(val) as AdminConfig) : null; } @@ -1803,7 +2112,9 @@ export abstract class BaseRedisStorage implements IStorage { } const pattern = `u:${userName}:skip:*`; - const oldKeys: string[] = await this.withRetry(() => this.adapter.keys(pattern)); + const oldKeys: string[] = await this.withRetry(() => + this.adapter.keys(pattern) + ); if (oldKeys.length === 0) { console.log(`用户 ${userName} 没有旧的跳过配置,标记为已迁移`); @@ -1833,7 +2144,9 @@ export abstract class BaseRedisStorage implements IStorage { await this.withRetry(() => this.adapter.hSet(this.skipHashKey(userName), hashData) ); - console.log(`成功迁移 ${Object.keys(hashData).length} 条跳过配置到hash结构`); + console.log( + `成功迁移 ${Object.keys(hashData).length} 条跳过配置到hash结构` + ); } await this.withRetry(() => this.adapter.del(oldKeys)); @@ -1855,7 +2168,9 @@ export abstract class BaseRedisStorage implements IStorage { const val = await this.withRetry(() => this.adapter.get(this.danmakuFilterConfigKey(userName)) ); - return val ? (JSON.parse(val) as import('./types').DanmakuFilterConfig) : null; + return val + ? (JSON.parse(val) as import('./types').DanmakuFilterConfig) + : null; } async setDanmakuFilterConfig( @@ -1928,7 +2243,9 @@ export abstract class BaseRedisStorage implements IStorage { return `u:${userName}:last_fav_check`; } - async getNotifications(userName: string): Promise { + async getNotifications( + userName: string + ): Promise { const val = await this.withRetry(() => this.adapter.get(this.notificationsKey(userName)) ); @@ -1946,7 +2263,10 @@ export abstract class BaseRedisStorage implements IStorage { notifications.splice(100); } await this.withRetry(() => - this.adapter.set(this.notificationsKey(userName), JSON.stringify(notifications)) + this.adapter.set( + this.notificationsKey(userName), + JSON.stringify(notifications) + ) ); } @@ -1959,7 +2279,10 @@ export abstract class BaseRedisStorage implements IStorage { if (notification) { notification.read = true; await this.withRetry(() => - this.adapter.set(this.notificationsKey(userName), JSON.stringify(notifications)) + this.adapter.set( + this.notificationsKey(userName), + JSON.stringify(notifications) + ) ); } } @@ -1971,12 +2294,17 @@ export abstract class BaseRedisStorage implements IStorage { const notifications = await this.getNotifications(userName); const filtered = notifications.filter((n) => n.id !== notificationId); await this.withRetry(() => - this.adapter.set(this.notificationsKey(userName), JSON.stringify(filtered)) + this.adapter.set( + this.notificationsKey(userName), + JSON.stringify(filtered) + ) ); } async clearAllNotifications(userName: string): Promise { - await this.withRetry(() => this.adapter.del(this.notificationsKey(userName))); + await this.withRetry(() => + this.adapter.del(this.notificationsKey(userName)) + ); } async getUnreadNotificationCount(userName: string): Promise { @@ -1996,11 +2324,17 @@ export abstract class BaseRedisStorage implements IStorage { timestamp: number ): Promise { await this.withRetry(() => - this.adapter.set(this.lastFavoriteCheckKey(userName), timestamp.toString()) + this.adapter.set( + this.lastFavoriteCheckKey(userName), + timestamp.toString() + ) ); } - async updateLastMovieRequestTime(userName: string, timestamp: number): Promise { + async updateLastMovieRequestTime( + userName: string, + timestamp: number + ): Promise { await this.withRetry(() => this.adapter.hSet( this.userInfoKey(userName), @@ -2020,42 +2354,81 @@ export abstract class BaseRedisStorage implements IStorage { } async getAllMovieRequests(): Promise { - const data = await this.withRetry(() => this.adapter.hGetAll(this.movieRequestsKey())); + const data = await this.withRetry(() => + this.adapter.hGetAll(this.movieRequestsKey()) + ); if (!data || Object.keys(data).length === 0) return []; - return Object.values(data).map(v => JSON.parse(v) as import('./types').MovieRequest); + return Object.values(data).map( + (v) => JSON.parse(v) as import('./types').MovieRequest + ); } - async getMovieRequest(requestId: string): Promise { - const val = await this.withRetry(() => this.adapter.hGet(this.movieRequestsKey(), requestId)); + async getMovieRequest( + requestId: string + ): Promise { + const val = await this.withRetry(() => + this.adapter.hGet(this.movieRequestsKey(), requestId) + ); return val ? (JSON.parse(val) as import('./types').MovieRequest) : null; } - async createMovieRequest(request: import('./types').MovieRequest): Promise { - await this.withRetry(() => this.adapter.hSet(this.movieRequestsKey(), request.id, JSON.stringify(request))); + async createMovieRequest( + request: import('./types').MovieRequest + ): Promise { + await this.withRetry(() => + this.adapter.hSet( + this.movieRequestsKey(), + request.id, + JSON.stringify(request) + ) + ); } - async updateMovieRequest(requestId: string, updates: Partial): Promise { + async updateMovieRequest( + requestId: string, + updates: Partial + ): Promise { const existing = await this.getMovieRequest(requestId); if (!existing) throw new Error('Movie request not found'); const updated = { ...existing, ...updates }; - await this.withRetry(() => this.adapter.hSet(this.movieRequestsKey(), requestId, JSON.stringify(updated))); + await this.withRetry(() => + this.adapter.hSet( + this.movieRequestsKey(), + requestId, + JSON.stringify(updated) + ) + ); } async deleteMovieRequest(requestId: string): Promise { - await this.withRetry(() => this.adapter.hDel(this.movieRequestsKey(), requestId)); + await this.withRetry(() => + this.adapter.hDel(this.movieRequestsKey(), requestId) + ); } async getUserMovieRequests(userName: string): Promise { - const val = await this.withRetry(() => this.adapter.sMembers(this.userMovieRequestsKey(userName))); + const val = await this.withRetry(() => + this.adapter.sMembers(this.userMovieRequestsKey(userName)) + ); return val ? ensureStringArray(val) : []; } - async addUserMovieRequest(userName: string, requestId: string): Promise { - await this.withRetry(() => this.adapter.sAdd(this.userMovieRequestsKey(userName), requestId)); + async addUserMovieRequest( + userName: string, + requestId: string + ): Promise { + await this.withRetry(() => + this.adapter.sAdd(this.userMovieRequestsKey(userName), requestId) + ); } - async removeUserMovieRequest(userName: string, requestId: string): Promise { - await this.withRetry(() => this.adapter.sRem(this.userMovieRequestsKey(userName), requestId)); + async removeUserMovieRequest( + userName: string, + requestId: string + ): Promise { + await this.withRetry(() => + this.adapter.sRem(this.userMovieRequestsKey(userName), requestId) + ); } // ---------- 用户邮箱相关 ---------- @@ -2077,9 +2450,16 @@ export abstract class BaseRedisStorage implements IStorage { return userInfo?.emailNotifications || false; } - async setEmailNotificationPreference(userName: string, enabled: boolean): Promise { + async setEmailNotificationPreference( + userName: string, + enabled: boolean + ): Promise { await this.withRetry(() => - this.adapter.hSet(this.userInfoKey(userName), 'emailNotifications', enabled.toString()) + this.adapter.hSet( + this.userInfoKey(userName), + 'emailNotifications', + enabled.toString() + ) ); // 清除缓存 userInfoCache?.delete(userName); @@ -2097,7 +2477,11 @@ export abstract class BaseRedisStorage implements IStorage { async setTvboxSubscribeToken(userName: string, token: string): Promise { // 保存token到用户信息 await this.withRetry(() => - this.adapter.hSet(this.userInfoKey(userName), 'tvboxSubscribeToken', token) + this.adapter.hSet( + this.userInfoKey(userName), + 'tvboxSubscribeToken', + token + ) ); // 创建token到用户名的反向索引 diff --git a/src/lib/types.ts b/src/lib/types.ts index 920cfa3..846d5d9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -42,6 +42,7 @@ export interface IStorage { ): Promise; getAllPlayRecords(userName: string): Promise<{ [key: string]: PlayRecord }>; deletePlayRecord(userName: string, key: string): Promise; + deletePlayRecords(userName: string, keys: string[]): Promise; // 清理超出限制的旧播放记录 cleanupOldPlayRecords(userName: string): Promise; // 迁移播放记录 @@ -58,7 +59,10 @@ export interface IStorage { // 音乐播放记录相关 getMusicPlayRecord(userName: string, key: string): Promise; setMusicPlayRecord(userName: string, key: string, record: any): Promise; - batchSetMusicPlayRecords(userName: string, records: { key: string; record: any }[]): Promise; + batchSetMusicPlayRecords( + userName: string, + records: { key: string; record: any }[] + ): Promise; getAllMusicPlayRecords(userName: string): Promise<{ [key: string]: any }>; deleteMusicPlayRecord(userName: string, key: string): Promise; clearAllMusicPlayRecords(userName: string): Promise; @@ -79,27 +83,55 @@ export interface IStorage { // 漫画书架相关 getMangaShelf(userName: string, key: string): Promise; - setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise; - getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }>; + setMangaShelf( + userName: string, + key: string, + item: MangaShelfItem + ): Promise; + getAllMangaShelf( + userName: string + ): Promise<{ [key: string]: MangaShelfItem }>; deleteMangaShelf(userName: string, key: string): Promise; // 漫画阅读历史相关 - getMangaReadRecord(userName: string, key: string): Promise; - setMangaReadRecord(userName: string, key: string, record: MangaReadRecord): Promise; - getAllMangaReadRecords(userName: string): Promise<{ [key: string]: MangaReadRecord }>; + getMangaReadRecord( + userName: string, + key: string + ): Promise; + setMangaReadRecord( + userName: string, + key: string, + record: MangaReadRecord + ): Promise; + getAllMangaReadRecords( + userName: string + ): Promise<{ [key: string]: MangaReadRecord }>; deleteMangaReadRecord(userName: string, key: string): Promise; cleanupOldMangaReadRecords?(userName: string): Promise; // 电子书书架相关 getBookShelf(userName: string, key: string): Promise; - setBookShelf(userName: string, key: string, item: BookShelfItem): Promise; + setBookShelf( + userName: string, + key: string, + item: BookShelfItem + ): Promise; getAllBookShelf(userName: string): Promise<{ [key: string]: BookShelfItem }>; deleteBookShelf(userName: string, key: string): Promise; // 电子书阅读历史相关 - getBookReadRecord(userName: string, key: string): Promise; - setBookReadRecord(userName: string, key: string, record: BookReadRecord): Promise; - getAllBookReadRecords(userName: string): Promise<{ [key: string]: BookReadRecord }>; + getBookReadRecord( + userName: string, + key: string + ): Promise; + setBookReadRecord( + userName: string, + key: string, + record: BookReadRecord + ): Promise; + getAllBookReadRecords( + userName: string + ): Promise<{ [key: string]: BookReadRecord }>; deleteBookReadRecord(userName: string, key: string): Promise; cleanupOldBookReadRecords?(userName: string): Promise; @@ -146,7 +178,10 @@ export interface IStorage { // 通知相关 getNotifications(userName: string): Promise; addNotification(userName: string, notification: Notification): Promise; - markNotificationAsRead(userName: string, notificationId: string): Promise; + markNotificationAsRead( + userName: string, + notificationId: string + ): Promise; deleteNotification(userName: string, notificationId: string): Promise; clearAllNotifications(userName: string): Promise; getUnreadNotificationCount(userName: string): Promise; @@ -156,13 +191,19 @@ export interface IStorage { setLastFavoriteCheckTime(userName: string, timestamp: number): Promise; // 求片冷却时间 - updateLastMovieRequestTime?(userName: string, timestamp: number): Promise; + updateLastMovieRequestTime?( + userName: string, + timestamp: number + ): Promise; // 求片相关 getAllMovieRequests(): Promise; getMovieRequest(requestId: string): Promise; createMovieRequest(request: MovieRequest): Promise; - updateMovieRequest(requestId: string, updates: Partial): Promise; + updateMovieRequest( + requestId: string, + updates: Partial + ): Promise; deleteMovieRequest(requestId: string): Promise; getUserMovieRequests(userName: string): Promise; addUserMovieRequest(userName: string, requestId: string): Promise; @@ -188,7 +229,10 @@ export interface IStorage { getUserEmail?(userName: string): Promise; setUserEmail?(userName: string, email: string): Promise; getEmailNotificationPreference?(userName: string): Promise; - setEmailNotificationPreference?(userName: string, enabled: boolean): Promise; + setEmailNotificationPreference?( + userName: string, + enabled: boolean + ): Promise; // TVBox订阅token相关 getTvboxSubscribeToken?(userName: string): Promise;