增加播放记录批量删除功能
This commit is contained in:
@@ -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,7 +110,9 @@ export async function POST(request: NextRequest) {
|
||||
await db.savePlayRecord(authInfo.username, source, id, finalRecord);
|
||||
|
||||
// 异步清理旧的播放记录(不阻塞响应)
|
||||
(db as any).storage.cleanupOldPlayRecords(authInfo.username).catch((err: Error) => {
|
||||
(db as any).storage
|
||||
.cleanupOldPlayRecords(authInfo.username)
|
||||
.catch((err: Error) => {
|
||||
console.error('异步清理播放记录失败:', 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 没有对应方法,这里直接遍历删除
|
||||
|
||||
@@ -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<PlayRecordItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(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 (
|
||||
<>
|
||||
<div
|
||||
@@ -115,14 +186,48 @@ export default function PlayRecordsPanel({
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
{playRecords.length > 0 && (
|
||||
{playRecords.length > 0 &&
|
||||
(editMode ? (
|
||||
<>
|
||||
<button
|
||||
onClick={
|
||||
allSelected ? () => setSelectedKeys(new Set()) : selectAll
|
||||
}
|
||||
className='text-xs text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white transition-colors'
|
||||
>
|
||||
{allSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteSelectedDialog(true)}
|
||||
disabled={selectedCount === 0 || deletingSelected}
|
||||
className='inline-flex items-center gap-1 text-xs text-red-500 hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-40 dark:text-red-400 dark:hover:text-red-300 transition-colors'
|
||||
>
|
||||
<Trash2 className='w-3.5 h-3.5' />
|
||||
删除{selectedCount > 0 ? `(${selectedCount})` : ''}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleEditMode}
|
||||
className='text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors'
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={toggleEditMode}
|
||||
className='text-xs text-sky-600 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300 transition-colors'
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowConfirmDialog(true)}
|
||||
className='text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors'
|
||||
>
|
||||
清空全部
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
|
||||
@@ -147,9 +252,10 @@ export default function PlayRecordsPanel({
|
||||
<div className='grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'>
|
||||
{playRecords.map((record) => {
|
||||
const { source, id } = parseKey(record.key);
|
||||
const checked = selectedKeys.has(record.key);
|
||||
|
||||
return (
|
||||
<div key={record.key} className='w-full'>
|
||||
<div key={record.key} className='relative w-full'>
|
||||
<VideoCard
|
||||
id={id}
|
||||
title={record.title}
|
||||
@@ -172,6 +278,34 @@ export default function PlayRecordsPanel({
|
||||
playTime={record.play_time}
|
||||
totalTime={record.total_time}
|
||||
/>
|
||||
{editMode && (
|
||||
<button
|
||||
type='button'
|
||||
aria-label={
|
||||
checked ? '取消选择播放记录' : '选择播放记录'
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleSelected(record.key);
|
||||
}}
|
||||
className={`absolute inset-0 z-20 rounded-lg transition-colors ${
|
||||
checked
|
||||
? 'bg-sky-500/15 ring-2 ring-sky-500'
|
||||
: 'bg-black/5 hover:bg-sky-500/10 dark:bg-black/20'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-2 top-2 flex h-6 w-6 items-center justify-center rounded-full border-2 shadow-md transition-colors ${
|
||||
checked
|
||||
? 'border-sky-500 bg-sky-500 text-white'
|
||||
: 'border-white bg-black/40 text-transparent'
|
||||
}`}
|
||||
>
|
||||
<Check className='h-4 w-4' />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -224,6 +358,56 @@ export default function PlayRecordsPanel({
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{showDeleteSelectedDialog &&
|
||||
createPortal(
|
||||
<div
|
||||
className='fixed inset-0 bg-black bg-opacity-50 z-[9999] flex items-center justify-center p-4 transition-opacity duration-300'
|
||||
onClick={() =>
|
||||
!deletingSelected && setShowDeleteSelectedDialog(false)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full border border-red-200 dark:border-red-800 transition-all duration-300'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className='p-6'>
|
||||
<div className='flex items-start gap-4 mb-4'>
|
||||
<div className='flex-shrink-0'>
|
||||
<AlertTriangle className='w-8 h-8 text-red-500' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
|
||||
删除播放记录
|
||||
</h3>
|
||||
<p className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
确定要删除选中的 {selectedCount}{' '}
|
||||
条播放记录吗?此操作不可恢复。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-3 mt-6'>
|
||||
<button
|
||||
onClick={() => setShowDeleteSelectedDialog(false)}
|
||||
disabled={deletingSelected}
|
||||
className='flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-60 rounded-lg transition-colors'
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={deletingSelected}
|
||||
className='flex-1 px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-60 rounded-lg transition-colors'
|
||||
>
|
||||
{deletingSelected ? '删除中...' : '确定删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+657
-206
@@ -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<PlayRecord | null> {
|
||||
async getPlayRecord(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<PlayRecord | null> {
|
||||
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<void> {
|
||||
async setPlayRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: PlayRecord
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
async setFavorite(
|
||||
userName: string,
|
||||
key: string,
|
||||
favorite: Favorite
|
||||
): Promise<void> {
|
||||
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<any | null> {
|
||||
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<void> {
|
||||
async setMusicPlayRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: any
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async batchSetMusicPlayRecords(
|
||||
userName: string,
|
||||
records: { key: string; record: any }[]
|
||||
): Promise<void> {
|
||||
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,8 +481,8 @@ export class D1Storage implements IStorage {
|
||||
play_time = excluded.play_time,
|
||||
duration = excluded.duration,
|
||||
save_time = excluded.save_time
|
||||
`)
|
||||
.bind(
|
||||
`
|
||||
).bind(
|
||||
userName,
|
||||
key,
|
||||
record.platform,
|
||||
@@ -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<void> {
|
||||
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: {
|
||||
async createMusicPlaylist(
|
||||
userName: string,
|
||||
playlist: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
}): Promise<void> {
|
||||
}
|
||||
): Promise<void> {
|
||||
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<any[]> {
|
||||
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: {
|
||||
async updateMusicPlaylist(
|
||||
playlistId: string,
|
||||
updates: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
}): Promise<void> {
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
@@ -618,7 +706,9 @@ export class D1Storage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
async addSongToPlaylist(playlistId: string, song: {
|
||||
async addSongToPlaylist(
|
||||
playlistId: string,
|
||||
song: {
|
||||
platform: string;
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -626,20 +716,24 @@ export class D1Storage implements IStorage {
|
||||
album?: string;
|
||||
pic?: string;
|
||||
duration: number;
|
||||
}): Promise<void> {
|
||||
}
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async removeSongFromPlaylist(
|
||||
playlistId: string,
|
||||
platform: string,
|
||||
songId: string
|
||||
): Promise<void> {
|
||||
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<any[]> {
|
||||
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<boolean> {
|
||||
async isSongInPlaylist(
|
||||
playlistId: string,
|
||||
platform: string,
|
||||
songId: string
|
||||
): Promise<boolean> {
|
||||
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<void> {
|
||||
async upsertMusicV2History(
|
||||
userName: string,
|
||||
record: MusicV2HistoryRecord
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async batchUpsertMusicV2History(
|
||||
userName: string,
|
||||
records: MusicV2HistoryRecord[]
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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: {
|
||||
async createMusicV2Playlist(
|
||||
userName: string,
|
||||
playlist: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
}): Promise<void> {
|
||||
}
|
||||
): Promise<void> {
|
||||
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<MusicV2PlaylistRecord | null> {
|
||||
async getMusicV2Playlist(
|
||||
playlistId: string
|
||||
): Promise<MusicV2PlaylistRecord | null> {
|
||||
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<MusicV2PlaylistRecord[]> {
|
||||
async listMusicV2Playlists(
|
||||
userName: string
|
||||
): Promise<MusicV2PlaylistRecord[]> {
|
||||
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: {
|
||||
async updateMusicV2Playlist(
|
||||
playlistId: string,
|
||||
updates: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
song_count?: number;
|
||||
}): Promise<void> {
|
||||
}
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async addMusicV2PlaylistItem(
|
||||
playlistId: string,
|
||||
item: MusicV2PlaylistItem
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async removeMusicV2PlaylistItem(
|
||||
playlistId: string,
|
||||
songId: string
|
||||
): Promise<void> {
|
||||
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<MusicV2PlaylistItem[]> {
|
||||
async listMusicV2PlaylistItems(
|
||||
playlistId: string
|
||||
): Promise<MusicV2PlaylistItem[]> {
|
||||
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<boolean> {
|
||||
async hasMusicV2PlaylistItem(
|
||||
playlistId: string,
|
||||
songId: string
|
||||
): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
async setUserPasswordHash(
|
||||
userName: string,
|
||||
passwordHash: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare('UPDATE users SET password_hash = ? WHERE username = ?')
|
||||
@@ -1625,14 +1823,16 @@ export class D1Storage implements IStorage {
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async setEmailNotificationPreference?(
|
||||
userName: string,
|
||||
enabled: boolean
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async setTvboxSubscribeToken?(
|
||||
userName: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
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<string[]> {
|
||||
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<MangaShelfItem | null> {
|
||||
async getMangaShelf(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<MangaShelfItem | null> {
|
||||
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<void> {
|
||||
async setMangaShelf(
|
||||
userName: string,
|
||||
key: string,
|
||||
item: MangaShelfItem
|
||||
): Promise<void> {
|
||||
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<MangaReadRecord | null> {
|
||||
async getMangaReadRecord(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<MangaReadRecord | null> {
|
||||
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<void> {
|
||||
async setMangaReadRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: MangaReadRecord
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<BookShelfItem | null> {
|
||||
async getBookShelf(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<BookShelfItem | null> {
|
||||
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<void> {
|
||||
async setBookShelf(
|
||||
userName: string,
|
||||
key: string,
|
||||
item: BookShelfItem
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<BookReadRecord | null> {
|
||||
async getBookReadRecord(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<BookReadRecord | null> {
|
||||
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<void> {
|
||||
async setBookReadRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: BookReadRecord
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<SkipConfig | null> {
|
||||
async getSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<SkipConfig | null> {
|
||||
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<void> {
|
||||
async setSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string,
|
||||
config: SkipConfig
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async deleteSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
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<DanmakuFilterConfig | null> {
|
||||
async getDanmakuFilterConfig(
|
||||
userName: string
|
||||
): Promise<DanmakuFilterConfig | null> {
|
||||
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<void> {
|
||||
async setDanmakuFilterConfig(
|
||||
userName: string,
|
||||
config: DanmakuFilterConfig
|
||||
): Promise<void> {
|
||||
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<Notification[]> {
|
||||
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<void> {
|
||||
async addNotification(
|
||||
userName: string,
|
||||
notification: Notification
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async markNotificationAsRead(
|
||||
userName: string,
|
||||
notificationId: string
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async deleteNotification(
|
||||
userName: string,
|
||||
notificationId: string
|
||||
): Promise<void> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<MovieRequest>): Promise<void> {
|
||||
async updateMovieRequest(
|
||||
requestId: string,
|
||||
updates: Partial<MovieRequest>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
@@ -2732,7 +3150,9 @@ export class D1Storage implements IStorage {
|
||||
async getUserMovieRequests(userName: string): Promise<string[]> {
|
||||
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<void> {
|
||||
async addUserMovieRequest(
|
||||
userName: string,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async removeUserMovieRequest(
|
||||
userName: string,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
async setLastFavoriteCheckTime(
|
||||
userName: string,
|
||||
timestamp: number
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async updateLastMovieRequestTime(
|
||||
userName: string,
|
||||
timestamp: number
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
+221
-55
@@ -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<T>(
|
||||
key: string,
|
||||
fetcher: () => Promise<T>
|
||||
): Promise<T> {
|
||||
getOrCreateRequest<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||
// 如果已有正在进行的请求,直接返回
|
||||
if (this.pendingRequests.has(key)) {
|
||||
console.log(`[${key}] 复用进行中的请求`);
|
||||
@@ -153,8 +151,7 @@ class HybridCacheManager {
|
||||
|
||||
console.log(`[${key}] 创建新请求`);
|
||||
// 创建新请求
|
||||
const promise = fetcher()
|
||||
.finally(() => {
|
||||
const promise = fetcher().finally(() => {
|
||||
// 请求完成后清除缓存
|
||||
this.pendingRequests.delete(key);
|
||||
});
|
||||
@@ -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<void> {
|
||||
console.error(`数据库操作失败 (${dataType}):`, error);
|
||||
@@ -598,12 +603,16 @@ async function handleDatabaseOperationFailure(
|
||||
eventName = 'searchHistoryUpdated';
|
||||
break;
|
||||
case 'mangaShelf':
|
||||
freshData = await fetchFromApi<Record<string, MangaShelfItem>>(`/api/manga/shelf`);
|
||||
freshData = await fetchFromApi<Record<string, MangaShelfItem>>(
|
||||
`/api/manga/shelf`
|
||||
);
|
||||
cacheManager.cacheMangaShelf(freshData);
|
||||
eventName = 'mangaShelfUpdated';
|
||||
break;
|
||||
case 'mangaHistory':
|
||||
freshData = await fetchFromApi<Record<string, MangaReadRecord>>(`/api/manga/history`);
|
||||
freshData = await fetchFromApi<Record<string, MangaReadRecord>>(
|
||||
`/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<string, PlayRecord> {
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedMangaReadRecordsSnapshot(): Record<string, MangaReadRecord> {
|
||||
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<void> {
|
||||
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)}`, {
|
||||
await fetchWithAuth(
|
||||
`/api/playrecords?key=${encodeURIComponent(fromKey)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
persistMove().catch((err) => {
|
||||
@@ -1307,7 +1398,8 @@ export async function deleteSearchHistory(keyword: string): Promise<void> {
|
||||
|
||||
// 模块级别的防重复请求机制
|
||||
let pendingFavoritesBackgroundRequest: Promise<void> | null = null;
|
||||
let pendingFavoritesFetchRequest: Promise<Record<string, Favorite>> | null = null;
|
||||
let pendingFavoritesFetchRequest: Promise<Record<string, Favorite>> | null =
|
||||
null;
|
||||
let lastFavoritesBackgroundFetchTime = 0;
|
||||
const MIN_BACKGROUND_FETCH_INTERVAL = 3000; // 3秒内不重复后台请求
|
||||
|
||||
@@ -1329,12 +1421,18 @@ export async function getAllFavorites(): Promise<Record<string, Favorite>> {
|
||||
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<Record<string, Favorite>>(`/api/favorites`);
|
||||
const freshData = await fetchFromApi<Record<string, Favorite>>(
|
||||
`/api/favorites`
|
||||
);
|
||||
// 只有数据真正不同时才更新缓存
|
||||
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
||||
cacheManager.cacheFavorites(freshData);
|
||||
@@ -1363,7 +1461,9 @@ export async function getAllFavorites(): Promise<Record<string, Favorite>> {
|
||||
|
||||
pendingFavoritesFetchRequest = (async () => {
|
||||
try {
|
||||
const freshData = await fetchFromApi<Record<string, Favorite>>(`/api/favorites`);
|
||||
const freshData = await fetchFromApi<Record<string, Favorite>>(
|
||||
`/api/favorites`
|
||||
);
|
||||
cacheManager.cacheFavorites(freshData);
|
||||
return freshData;
|
||||
} catch (err) {
|
||||
@@ -1627,10 +1727,11 @@ export async function clearAllFavorites(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ---------------- 漫画书架 / 历史 API ----------------
|
||||
|
||||
export async function getAllMangaShelf(): Promise<Record<string, MangaShelfItem>> {
|
||||
export async function getAllMangaShelf(): Promise<
|
||||
Record<string, MangaShelfItem>
|
||||
> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
@@ -1640,7 +1741,9 @@ export async function getAllMangaShelf(): Promise<Record<string, MangaShelfItem>
|
||||
.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<Record<string, MangaShelfItem>
|
||||
}
|
||||
|
||||
try {
|
||||
const freshData = await fetchFromApi<Record<string, MangaShelfItem>>('/api/manga/shelf');
|
||||
const freshData = await fetchFromApi<Record<string, MangaShelfItem>>(
|
||||
'/api/manga/shelf'
|
||||
);
|
||||
cacheManager.cacheMangaShelf(freshData);
|
||||
return freshData;
|
||||
} catch (err) {
|
||||
@@ -1671,14 +1776,20 @@ export async function getAllMangaShelf(): Promise<Record<string, MangaShelfItem>
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMangaShelf(sourceId: string, mangaId: string, item: MangaShelfItem): Promise<void> {
|
||||
export async function saveMangaShelf(
|
||||
sourceId: string,
|
||||
mangaId: string,
|
||||
item: MangaShelfItem
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
export async function deleteMangaShelf(
|
||||
sourceId: string,
|
||||
mangaId: string
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
@@ -1740,7 +1862,9 @@ export async function clearAllMangaShelf(): Promise<void> {
|
||||
window.dispatchEvent(new CustomEvent('mangaShelfUpdated', { detail: {} }));
|
||||
}
|
||||
|
||||
function trimMangaReadRecords(records: Record<string, MangaReadRecord>): Record<string, MangaReadRecord> {
|
||||
function trimMangaReadRecords(
|
||||
records: Record<string, MangaReadRecord>
|
||||
): Record<string, MangaReadRecord> {
|
||||
const entries = Object.entries(records);
|
||||
if (entries.length <= DEFAULT_MAX_MANGA_HISTORY_THRESHOLD) return records;
|
||||
|
||||
@@ -1751,7 +1875,9 @@ function trimMangaReadRecords(records: Record<string, MangaReadRecord>): Record<
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAllMangaReadRecords(): Promise<Record<string, MangaReadRecord>> {
|
||||
export async function getAllMangaReadRecords(): Promise<
|
||||
Record<string, MangaReadRecord>
|
||||
> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
@@ -1761,7 +1887,9 @@ export async function getAllMangaReadRecords(): Promise<Record<string, MangaRead
|
||||
.then((freshData) => {
|
||||
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<Record<string, MangaRead
|
||||
}
|
||||
|
||||
try {
|
||||
const freshData = await fetchFromApi<Record<string, MangaReadRecord>>('/api/manga/history');
|
||||
const freshData = await fetchFromApi<Record<string, MangaReadRecord>>(
|
||||
'/api/manga/history'
|
||||
);
|
||||
cacheManager.cacheMangaReadRecords(freshData);
|
||||
return freshData;
|
||||
} catch (err) {
|
||||
@@ -1792,7 +1922,11 @@ export async function getAllMangaReadRecords(): Promise<Record<string, MangaRead
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMangaReadRecord(sourceId: string, mangaId: string, record: MangaReadRecord): Promise<void> {
|
||||
export async function saveMangaReadRecord(
|
||||
sourceId: string,
|
||||
mangaId: string,
|
||||
record: MangaReadRecord
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
export async function deleteMangaReadRecord(
|
||||
sourceId: string,
|
||||
mangaId: string
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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,8 +2035,14 @@ export async function refreshAllCache(): Promise<void> {
|
||||
// 使用 Promise 缓存防止并发重复刷新
|
||||
await cacheManager.getOrCreateRequest('refresh-all-cache', async () => {
|
||||
// 并行刷新所有数据
|
||||
const [playRecords, favorites, mangaShelf, mangaHistory, searchHistory, skipConfigs] =
|
||||
await Promise.allSettled([
|
||||
const [
|
||||
playRecords,
|
||||
favorites,
|
||||
mangaShelf,
|
||||
mangaHistory,
|
||||
searchHistory,
|
||||
skipConfigs,
|
||||
] = await Promise.allSettled([
|
||||
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`),
|
||||
fetchFromApi<Record<string, Favorite>>(`/api/favorites`),
|
||||
fetchFromApi<Record<string, MangaShelfItem>>(`/api/manga/shelf`),
|
||||
@@ -2020,7 +2175,7 @@ export function subscribeToDataUpdates<T>(
|
||||
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<Record<string, MusicPlayRecord>> {
|
||||
export async function getAllMusicPlayRecords(): Promise<
|
||||
Record<string, MusicPlayRecord>
|
||||
> {
|
||||
// 服务器端渲染阶段直接返回空
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
@@ -2594,9 +2754,12 @@ export async function deleteMusicPlayRecord(
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
await fetchWithAuth(`/api/music/playrecords?key=${encodeURIComponent(key)}`, {
|
||||
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,
|
||||
|
||||
+229
-53
@@ -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,23 +87,30 @@ 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({}, {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(target, prop) {
|
||||
// 懒加载:第一次访问时才获取真实的 D1 适配器
|
||||
if (!cachedAdapter) {
|
||||
try {
|
||||
const { getCloudflareContext } = require('@opennextjs/cloudflare');
|
||||
const {
|
||||
getCloudflareContext,
|
||||
} = require('@opennextjs/cloudflare');
|
||||
const { env } = getCloudflareContext();
|
||||
|
||||
if (!env.DB) {
|
||||
throw new Error('D1 database binding (DB) not found in Cloudflare environment');
|
||||
throw new Error(
|
||||
'D1 database binding (DB) not found in Cloudflare environment'
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Using Cloudflare D1 database');
|
||||
@@ -105,8 +122,9 @@ function getD1Adapter(): any {
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
async upsertMusicV2History(
|
||||
userName: string,
|
||||
record: MusicV2HistoryRecord
|
||||
): Promise<void> {
|
||||
if (typeof (this.storage as any).upsertMusicV2History === 'function') {
|
||||
await (this.storage as any).upsertMusicV2History(userName, record);
|
||||
}
|
||||
}
|
||||
|
||||
async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise<void> {
|
||||
async batchUpsertMusicV2History(
|
||||
userName: string,
|
||||
records: MusicV2HistoryRecord[]
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
if (typeof (this.storage as any).createMusicV2Playlist === 'function') {
|
||||
await (this.storage as any).createMusicV2Playlist(userName, playlist);
|
||||
}
|
||||
}
|
||||
|
||||
async getMusicV2Playlist(playlistId: string): Promise<MusicV2PlaylistRecord | null> {
|
||||
async getMusicV2Playlist(
|
||||
playlistId: string
|
||||
): Promise<MusicV2PlaylistRecord | null> {
|
||||
if (typeof (this.storage as any).getMusicV2Playlist === 'function') {
|
||||
return (this.storage as any).getMusicV2Playlist(playlistId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async listMusicV2Playlists(userName: string): Promise<MusicV2PlaylistRecord[]> {
|
||||
async listMusicV2Playlists(
|
||||
userName: string
|
||||
): Promise<MusicV2PlaylistRecord[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
async addMusicV2PlaylistItem(
|
||||
playlistId: string,
|
||||
item: MusicV2PlaylistItem
|
||||
): Promise<void> {
|
||||
if (typeof (this.storage as any).addMusicV2PlaylistItem === 'function') {
|
||||
await (this.storage as any).addMusicV2PlaylistItem(playlistId, item);
|
||||
}
|
||||
}
|
||||
|
||||
async removeMusicV2PlaylistItem(playlistId: string, songId: string): Promise<void> {
|
||||
async removeMusicV2PlaylistItem(
|
||||
playlistId: string,
|
||||
songId: string
|
||||
): Promise<void> {
|
||||
if (typeof (this.storage as any).removeMusicV2PlaylistItem === 'function') {
|
||||
await (this.storage as any).removeMusicV2PlaylistItem(playlistId, songId);
|
||||
}
|
||||
}
|
||||
|
||||
async listMusicV2PlaylistItems(playlistId: string): Promise<MusicV2PlaylistItem[]> {
|
||||
async listMusicV2PlaylistItems(
|
||||
playlistId: string
|
||||
): Promise<MusicV2PlaylistItem[]> {
|
||||
if (typeof (this.storage as any).listMusicV2PlaylistItems === 'function') {
|
||||
return (this.storage as any).listMusicV2PlaylistItems(playlistId);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async hasMusicV2PlaylistItem(playlistId: string, songId: string): Promise<boolean> {
|
||||
async hasMusicV2PlaylistItem(
|
||||
playlistId: string,
|
||||
songId: string
|
||||
): Promise<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<MangaShelfItem | null> {
|
||||
return this.storage.getMangaShelf(userName, generateStorageKey(sourceId, mangaId));
|
||||
async getMangaShelf(
|
||||
userName: string,
|
||||
sourceId: string,
|
||||
mangaId: string
|
||||
): Promise<MangaShelfItem | null> {
|
||||
return this.storage.getMangaShelf(
|
||||
userName,
|
||||
generateStorageKey(sourceId, mangaId)
|
||||
);
|
||||
}
|
||||
|
||||
async saveMangaShelf(userName: string, sourceId: string, mangaId: string, item: MangaShelfItem): Promise<void> {
|
||||
await this.storage.setMangaShelf(userName, generateStorageKey(sourceId, mangaId), item);
|
||||
async saveMangaShelf(
|
||||
userName: string,
|
||||
sourceId: string,
|
||||
mangaId: string,
|
||||
item: MangaShelfItem
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
await this.storage.deleteMangaShelf(userName, generateStorageKey(sourceId, mangaId));
|
||||
async deleteMangaShelf(
|
||||
userName: string,
|
||||
sourceId: string,
|
||||
mangaId: string
|
||||
): Promise<void> {
|
||||
await this.storage.deleteMangaShelf(
|
||||
userName,
|
||||
generateStorageKey(sourceId, mangaId)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 漫画阅读历史 ----------
|
||||
async getMangaReadRecord(userName: string, sourceId: string, mangaId: string): Promise<MangaReadRecord | null> {
|
||||
return this.storage.getMangaReadRecord(userName, generateStorageKey(sourceId, mangaId));
|
||||
async getMangaReadRecord(
|
||||
userName: string,
|
||||
sourceId: string,
|
||||
mangaId: string
|
||||
): Promise<MangaReadRecord | null> {
|
||||
return this.storage.getMangaReadRecord(
|
||||
userName,
|
||||
generateStorageKey(sourceId, mangaId)
|
||||
);
|
||||
}
|
||||
|
||||
async saveMangaReadRecord(userName: string, sourceId: string, mangaId: string, record: MangaReadRecord): Promise<void> {
|
||||
await this.storage.setMangaReadRecord(userName, generateStorageKey(sourceId, mangaId), record);
|
||||
async saveMangaReadRecord(
|
||||
userName: string,
|
||||
sourceId: string,
|
||||
mangaId: string,
|
||||
record: MangaReadRecord
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
await this.storage.deleteMangaReadRecord(userName, generateStorageKey(sourceId, mangaId));
|
||||
async deleteMangaReadRecord(
|
||||
userName: string,
|
||||
sourceId: string,
|
||||
mangaId: string
|
||||
): Promise<void> {
|
||||
await this.storage.deleteMangaReadRecord(
|
||||
userName,
|
||||
generateStorageKey(sourceId, mangaId)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 电子书书架 ----------
|
||||
async getBookShelf(userName: string, sourceId: string, bookId: string): Promise<BookShelfItem | null> {
|
||||
return this.storage.getBookShelf(userName, generateStorageKey(sourceId, bookId));
|
||||
async getBookShelf(
|
||||
userName: string,
|
||||
sourceId: string,
|
||||
bookId: string
|
||||
): Promise<BookShelfItem | null> {
|
||||
return this.storage.getBookShelf(
|
||||
userName,
|
||||
generateStorageKey(sourceId, bookId)
|
||||
);
|
||||
}
|
||||
|
||||
async saveBookShelf(userName: string, sourceId: string, bookId: string, item: BookShelfItem): Promise<void> {
|
||||
await this.storage.setBookShelf(userName, generateStorageKey(sourceId, bookId), item);
|
||||
async saveBookShelf(
|
||||
userName: string,
|
||||
sourceId: string,
|
||||
bookId: string,
|
||||
item: BookShelfItem
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
await this.storage.deleteBookShelf(userName, generateStorageKey(sourceId, bookId));
|
||||
async deleteBookShelf(
|
||||
userName: string,
|
||||
sourceId: string,
|
||||
bookId: string
|
||||
): Promise<void> {
|
||||
await this.storage.deleteBookShelf(
|
||||
userName,
|
||||
generateStorageKey(sourceId, bookId)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 电子书阅读历史 ----------
|
||||
async getBookReadRecord(userName: string, sourceId: string, bookId: string): Promise<BookReadRecord | null> {
|
||||
return this.storage.getBookReadRecord(userName, generateStorageKey(sourceId, bookId));
|
||||
async getBookReadRecord(
|
||||
userName: string,
|
||||
sourceId: string,
|
||||
bookId: string
|
||||
): Promise<BookReadRecord | null> {
|
||||
return this.storage.getBookReadRecord(
|
||||
userName,
|
||||
generateStorageKey(sourceId, bookId)
|
||||
);
|
||||
}
|
||||
|
||||
async saveBookReadRecord(userName: string, sourceId: string, bookId: string, record: BookReadRecord): Promise<void> {
|
||||
await this.storage.setBookReadRecord(userName, generateStorageKey(sourceId, bookId), record);
|
||||
async saveBookReadRecord(
|
||||
userName: string,
|
||||
sourceId: string,
|
||||
bookId: string,
|
||||
record: BookReadRecord
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
await this.storage.deleteBookReadRecord(userName, generateStorageKey(sourceId, bookId));
|
||||
async deleteBookReadRecord(
|
||||
userName: string,
|
||||
sourceId: string,
|
||||
bookId: string
|
||||
): Promise<void> {
|
||||
await this.storage.deleteBookReadRecord(
|
||||
userName,
|
||||
generateStorageKey(sourceId, bookId)
|
||||
);
|
||||
}
|
||||
|
||||
// 获取全部用户名
|
||||
@@ -864,7 +1038,9 @@ export class DbManager {
|
||||
}
|
||||
|
||||
// ---------- 弹幕过滤配置 ----------
|
||||
async getDanmakuFilterConfig(userName: string): Promise<DanmakuFilterConfig | null> {
|
||||
async getDanmakuFilterConfig(
|
||||
userName: string
|
||||
): Promise<DanmakuFilterConfig | null> {
|
||||
if (typeof (this.storage as any).getDanmakuFilterConfig === 'function') {
|
||||
return (this.storage as any).getDanmakuFilterConfig(userName);
|
||||
}
|
||||
|
||||
+660
-207
@@ -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<PlayRecord | null> {
|
||||
async getPlayRecord(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<PlayRecord | null> {
|
||||
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<void> {
|
||||
async setPlayRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: PlayRecord
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
async setFavorite(
|
||||
userName: string,
|
||||
key: string,
|
||||
favorite: Favorite
|
||||
): Promise<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
async setUserPasswordHash(
|
||||
userName: string,
|
||||
passwordHash: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare('UPDATE users SET password_hash = $1 WHERE username = $2')
|
||||
@@ -879,14 +968,16 @@ export class PostgresStorage implements IStorage {
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async setEmailNotificationPreference(
|
||||
userName: string,
|
||||
enabled: boolean
|
||||
): Promise<void> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<any | null> {
|
||||
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<void> {
|
||||
async setMusicPlayRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: any
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async batchSetMusicPlayRecords(
|
||||
userName: string,
|
||||
records: { key: string; record: any }[]
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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: {
|
||||
async createMusicPlaylist(
|
||||
userName: string,
|
||||
playlist: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
}): Promise<void> {
|
||||
}
|
||||
): Promise<void> {
|
||||
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<any[]> {
|
||||
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: {
|
||||
async updateMusicPlaylist(
|
||||
playlistId: string,
|
||||
updates: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
}): Promise<void> {
|
||||
}
|
||||
): Promise<void> {
|
||||
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,7 +1444,9 @@ export class PostgresStorage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
async addSongToPlaylist(playlistId: string, song: {
|
||||
async addSongToPlaylist(
|
||||
playlistId: string,
|
||||
song: {
|
||||
platform: string;
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -1311,20 +1454,24 @@ export class PostgresStorage implements IStorage {
|
||||
album?: string;
|
||||
pic?: string;
|
||||
duration: number;
|
||||
}): Promise<void> {
|
||||
}
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async removeSongFromPlaylist(
|
||||
playlistId: string,
|
||||
platform: string,
|
||||
songId: string
|
||||
): Promise<void> {
|
||||
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<any[]> {
|
||||
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<void> {
|
||||
async updatePlaylistSongOrder(
|
||||
playlistId: string,
|
||||
songOrders: Array<{ platform: string; songId: string; sortOrder: number }>
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async upsertMusicV2History(
|
||||
userName: string,
|
||||
record: MusicV2HistoryRecord
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async batchUpsertMusicV2History(
|
||||
userName: string,
|
||||
records: MusicV2HistoryRecord[]
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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: {
|
||||
async createMusicV2Playlist(
|
||||
userName: string,
|
||||
playlist: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
}): Promise<void> {
|
||||
}
|
||||
): Promise<void> {
|
||||
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<MusicV2PlaylistRecord | null> {
|
||||
async getMusicV2Playlist(
|
||||
playlistId: string
|
||||
): Promise<MusicV2PlaylistRecord | null> {
|
||||
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<MusicV2PlaylistRecord[]> {
|
||||
async listMusicV2Playlists(
|
||||
userName: string
|
||||
): Promise<MusicV2PlaylistRecord[]> {
|
||||
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: {
|
||||
async updateMusicV2Playlist(
|
||||
playlistId: string,
|
||||
updates: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
song_count?: number;
|
||||
}): Promise<void> {
|
||||
}
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async addMusicV2PlaylistItem(
|
||||
playlistId: string,
|
||||
item: MusicV2PlaylistItem
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async removeMusicV2PlaylistItem(
|
||||
playlistId: string,
|
||||
songId: string
|
||||
): Promise<void> {
|
||||
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<MusicV2PlaylistItem[]> {
|
||||
async listMusicV2PlaylistItems(
|
||||
playlistId: string
|
||||
): Promise<MusicV2PlaylistItem[]> {
|
||||
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<boolean> {
|
||||
async hasMusicV2PlaylistItem(
|
||||
playlistId: string,
|
||||
songId: string
|
||||
): Promise<boolean> {
|
||||
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<string[]> {
|
||||
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<MangaShelfItem | null> {
|
||||
async getMangaShelf(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<MangaShelfItem | null> {
|
||||
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<void> {
|
||||
async setMangaShelf(
|
||||
userName: string,
|
||||
key: string,
|
||||
item: MangaShelfItem
|
||||
): Promise<void> {
|
||||
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<MangaReadRecord | null> {
|
||||
async getMangaReadRecord(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<MangaReadRecord | null> {
|
||||
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<void> {
|
||||
async setMangaReadRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: MangaReadRecord
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<BookShelfItem | null> {
|
||||
async getBookShelf(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<BookShelfItem | null> {
|
||||
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<void> {
|
||||
async setBookShelf(
|
||||
userName: string,
|
||||
key: string,
|
||||
item: BookShelfItem
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<BookReadRecord | null> {
|
||||
async getBookReadRecord(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<BookReadRecord | null> {
|
||||
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<void> {
|
||||
async setBookReadRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: BookReadRecord
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<SkipConfig | null> {
|
||||
async getSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<SkipConfig | null> {
|
||||
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<void> {
|
||||
async setSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string,
|
||||
config: SkipConfig
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async deleteSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
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<DanmakuFilterConfig | null> {
|
||||
async getDanmakuFilterConfig(
|
||||
userName: string
|
||||
): Promise<DanmakuFilterConfig | null> {
|
||||
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<void> {
|
||||
async setDanmakuFilterConfig(
|
||||
userName: string,
|
||||
config: DanmakuFilterConfig
|
||||
): Promise<void> {
|
||||
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<Notification[]> {
|
||||
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<void> {
|
||||
async addNotification(
|
||||
userName: string,
|
||||
notification: Notification
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async markNotificationAsRead(
|
||||
userName: string,
|
||||
notificationId: string
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async deleteNotification(
|
||||
userName: string,
|
||||
notificationId: string
|
||||
): Promise<void> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<MovieRequest>): Promise<void> {
|
||||
async updateMovieRequest(
|
||||
requestId: string,
|
||||
updates: Partial<MovieRequest>
|
||||
): Promise<void> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
async addUserMovieRequest(
|
||||
userName: string,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async removeUserMovieRequest(
|
||||
userName: string,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
async setLastFavoriteCheckTime(
|
||||
userName: string,
|
||||
timestamp: number
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async updateLastMovieRequestTime(
|
||||
userName: string,
|
||||
timestamp: number
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
+548
-164
@@ -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<T>(
|
||||
operation: () => Promise<T>,
|
||||
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: <T>(operation: () => Promise<T>, maxRetries?: number) => Promise<T>;
|
||||
protected withRetry: <T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries?: number
|
||||
) => Promise<T>;
|
||||
// 保留 client 属性用于向后兼容(数据迁移代码使用)
|
||||
client: any;
|
||||
|
||||
constructor(adapter: RedisAdapter, withRetryFn: <T>(operation: () => Promise<T>, maxRetries?: number) => Promise<T>) {
|
||||
constructor(
|
||||
adapter: RedisAdapter,
|
||||
withRetryFn: <T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries?: number
|
||||
) => Promise<T>
|
||||
) {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
async setMusicPlayRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: any
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async batchSetMusicPlayRecords(
|
||||
userName: string,
|
||||
records: { key: string; record: any }[]
|
||||
): Promise<void> {
|
||||
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<Record<string, any>> {
|
||||
@@ -594,12 +665,15 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
return `music_playlist:${playlistId}:songs`;
|
||||
}
|
||||
|
||||
async createMusicPlaylist(userName: string, playlist: {
|
||||
async createMusicPlaylist(
|
||||
userName: string,
|
||||
playlist: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
}): Promise<void> {
|
||||
}
|
||||
): Promise<void> {
|
||||
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: {
|
||||
async updateMusicPlaylist(
|
||||
playlistId: string,
|
||||
updates: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
}): Promise<void> {
|
||||
}
|
||||
): Promise<void> {
|
||||
const updateData: Record<string, string> = {
|
||||
updated_at: Date.now().toString(),
|
||||
};
|
||||
@@ -709,7 +786,9 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
);
|
||||
}
|
||||
|
||||
async addSongToPlaylist(playlistId: string, song: {
|
||||
async addSongToPlaylist(
|
||||
playlistId: string,
|
||||
song: {
|
||||
platform: string;
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -717,7 +796,8 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
album?: string;
|
||||
pic?: string;
|
||||
duration: number;
|
||||
}): Promise<void> {
|
||||
}
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async removeSongFromPlaylist(
|
||||
playlistId: string,
|
||||
platform: string,
|
||||
songId: string
|
||||
): Promise<void> {
|
||||
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<boolean> {
|
||||
async isSongInPlaylist(
|
||||
playlistId: string,
|
||||
platform: string,
|
||||
songId: string
|
||||
): Promise<boolean> {
|
||||
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 || {})
|
||||
return (
|
||||
Object.values(rows || {})
|
||||
.filter(Boolean)
|
||||
.map(value => JSON.parse(value as string) as MusicV2HistoryRecord)
|
||||
.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<void> {
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hSet(this.musicV2HistoryKey(userName), record.songId, JSON.stringify(record))
|
||||
return `${a.source}:${a.songId}`.localeCompare(
|
||||
`${b.source}:${b.songId}`
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async batchUpsertMusicV2History(userName: string, records: MusicV2HistoryRecord[]): Promise<void> {
|
||||
async upsertMusicV2History(
|
||||
userName: string,
|
||||
record: MusicV2HistoryRecord
|
||||
): Promise<void> {
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hSet(
|
||||
this.musicV2HistoryKey(userName),
|
||||
record.songId,
|
||||
JSON.stringify(record)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async batchUpsertMusicV2History(
|
||||
userName: string,
|
||||
records: MusicV2HistoryRecord[]
|
||||
): Promise<void> {
|
||||
if (!records.length) return;
|
||||
const payload: Record<string, string> = {};
|
||||
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<void> {
|
||||
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<void> {
|
||||
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: {
|
||||
async createMusicV2Playlist(
|
||||
userName: string,
|
||||
playlist: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
}): Promise<void> {
|
||||
}
|
||||
): Promise<void> {
|
||||
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<MusicV2PlaylistRecord | null> {
|
||||
const data = await this.withRetry(() => this.adapter.hGetAll(this.musicV2PlaylistKey(playlistId)));
|
||||
async getMusicV2Playlist(
|
||||
playlistId: string
|
||||
): Promise<MusicV2PlaylistRecord | null> {
|
||||
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<MusicV2PlaylistRecord[]> {
|
||||
const playlistIds = await this.withRetry(() => this.adapter.zRange(this.musicV2PlaylistsKey(userName), 0, -1));
|
||||
async listMusicV2Playlists(
|
||||
userName: string
|
||||
): Promise<MusicV2PlaylistRecord[]> {
|
||||
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: {
|
||||
async updateMusicV2Playlist(
|
||||
playlistId: string,
|
||||
updates: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
song_count?: number;
|
||||
}): Promise<void> {
|
||||
}
|
||||
): Promise<void> {
|
||||
const payload: Record<string, string> = {
|
||||
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<void> {
|
||||
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<void> {
|
||||
async addMusicV2PlaylistItem(
|
||||
playlistId: string,
|
||||
item: MusicV2PlaylistItem
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
await this.withRetry(() => this.adapter.hDel(this.musicV2PlaylistItemsKey(playlistId), songId));
|
||||
async removeMusicV2PlaylistItem(
|
||||
playlistId: string,
|
||||
songId: string
|
||||
): Promise<void> {
|
||||
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<MusicV2PlaylistItem[]> {
|
||||
const rows = await this.withRetry(() => this.adapter.hGetAll(this.musicV2PlaylistItemsKey(playlistId)));
|
||||
async listMusicV2PlaylistItems(
|
||||
playlistId: string
|
||||
): Promise<MusicV2PlaylistItem[]> {
|
||||
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<boolean> {
|
||||
const exists = await this.withRetry(() => this.adapter.hGet(this.musicV2PlaylistItemsKey(playlistId), songId));
|
||||
async hasMusicV2PlaylistItem(
|
||||
playlistId: string,
|
||||
songId: string
|
||||
): Promise<boolean> {
|
||||
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(), {
|
||||
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(), {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<MangaShelfItem | null> {
|
||||
const val = await this.withRetry(() => this.adapter.hGet(this.mangaShelfHashKey(userName), key));
|
||||
async getMangaShelf(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<MangaShelfItem | null> {
|
||||
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<void> {
|
||||
await this.withRetry(() => this.adapter.hSet(this.mangaShelfHashKey(userName), key, JSON.stringify(item)));
|
||||
async setMangaShelf(
|
||||
userName: string,
|
||||
key: string,
|
||||
item: MangaShelfItem
|
||||
): Promise<void> {
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hSet(
|
||||
this.mangaShelfHashKey(userName),
|
||||
key,
|
||||
JSON.stringify(item)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async getAllMangaShelf(userName: string): Promise<Record<string, MangaShelfItem>> {
|
||||
const hashData = await this.withRetry(() => this.adapter.hGetAll(this.mangaShelfHashKey(userName)));
|
||||
async getAllMangaShelf(
|
||||
userName: string
|
||||
): Promise<Record<string, MangaShelfItem>> {
|
||||
const hashData = await this.withRetry(() =>
|
||||
this.adapter.hGetAll(this.mangaShelfHashKey(userName))
|
||||
);
|
||||
const result: Record<string, MangaShelfItem> = {};
|
||||
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<void> {
|
||||
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<MangaReadRecord | null> {
|
||||
const val = await this.withRetry(() => this.adapter.hGet(this.mangaReadHashKey(userName), key));
|
||||
async getMangaReadRecord(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<MangaReadRecord | null> {
|
||||
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<void> {
|
||||
await this.withRetry(() => this.adapter.hSet(this.mangaReadHashKey(userName), key, JSON.stringify(record)));
|
||||
async setMangaReadRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: MangaReadRecord
|
||||
): Promise<void> {
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hSet(
|
||||
this.mangaReadHashKey(userName),
|
||||
key,
|
||||
JSON.stringify(record)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async getAllMangaReadRecords(userName: string): Promise<Record<string, MangaReadRecord>> {
|
||||
const hashData = await this.withRetry(() => this.adapter.hGetAll(this.mangaReadHashKey(userName)));
|
||||
async getAllMangaReadRecords(
|
||||
userName: string
|
||||
): Promise<Record<string, MangaReadRecord>> {
|
||||
const hashData = await this.withRetry(() =>
|
||||
this.adapter.hGetAll(this.mangaReadHashKey(userName))
|
||||
);
|
||||
const result: Record<string, MangaReadRecord> = {};
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<BookShelfItem | null> {
|
||||
const val = await this.withRetry(() => this.adapter.hGet(this.bookShelfHashKey(userName), key));
|
||||
async getBookShelf(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<BookShelfItem | null> {
|
||||
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<void> {
|
||||
await this.withRetry(() => this.adapter.hSet(this.bookShelfHashKey(userName), key, JSON.stringify(item)));
|
||||
async setBookShelf(
|
||||
userName: string,
|
||||
key: string,
|
||||
item: BookShelfItem
|
||||
): Promise<void> {
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hSet(
|
||||
this.bookShelfHashKey(userName),
|
||||
key,
|
||||
JSON.stringify(item)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async getAllBookShelf(userName: string): Promise<Record<string, BookShelfItem>> {
|
||||
const hashData = await this.withRetry(() => this.adapter.hGetAll(this.bookShelfHashKey(userName)));
|
||||
async getAllBookShelf(
|
||||
userName: string
|
||||
): Promise<Record<string, BookShelfItem>> {
|
||||
const hashData = await this.withRetry(() =>
|
||||
this.adapter.hGetAll(this.bookShelfHashKey(userName))
|
||||
);
|
||||
const result: Record<string, BookShelfItem> = {};
|
||||
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<void> {
|
||||
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<BookReadRecord | null> {
|
||||
const val = await this.withRetry(() => this.adapter.hGet(this.bookReadHashKey(userName), key));
|
||||
async getBookReadRecord(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<BookReadRecord | null> {
|
||||
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<void> {
|
||||
await this.withRetry(() => this.adapter.hSet(this.bookReadHashKey(userName), key, JSON.stringify(record)));
|
||||
async setBookReadRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: BookReadRecord
|
||||
): Promise<void> {
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hSet(
|
||||
this.bookReadHashKey(userName),
|
||||
key,
|
||||
JSON.stringify(record)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async getAllBookReadRecords(userName: string): Promise<Record<string, BookReadRecord>> {
|
||||
const hashData = await this.withRetry(() => this.adapter.hGetAll(this.bookReadHashKey(userName)));
|
||||
async getAllBookReadRecords(
|
||||
userName: string
|
||||
): Promise<Record<string, BookReadRecord>> {
|
||||
const hashData = await this.withRetry(() =>
|
||||
this.adapter.hGetAll(this.bookReadHashKey(userName))
|
||||
);
|
||||
const result: Record<string, BookReadRecord> = {};
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<AdminConfig | null> {
|
||||
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<import('./types').Notification[]> {
|
||||
async getNotifications(
|
||||
userName: string
|
||||
): Promise<import('./types').Notification[]> {
|
||||
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<void> {
|
||||
await this.withRetry(() => this.adapter.del(this.notificationsKey(userName)));
|
||||
await this.withRetry(() =>
|
||||
this.adapter.del(this.notificationsKey(userName))
|
||||
);
|
||||
}
|
||||
|
||||
async getUnreadNotificationCount(userName: string): Promise<number> {
|
||||
@@ -1996,11 +2324,17 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
timestamp: number
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async updateLastMovieRequestTime(
|
||||
userName: string,
|
||||
timestamp: number
|
||||
): Promise<void> {
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hSet(
|
||||
this.userInfoKey(userName),
|
||||
@@ -2020,42 +2354,81 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
}
|
||||
|
||||
async getAllMovieRequests(): Promise<import('./types').MovieRequest[]> {
|
||||
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<import('./types').MovieRequest | null> {
|
||||
const val = await this.withRetry(() => this.adapter.hGet(this.movieRequestsKey(), requestId));
|
||||
async getMovieRequest(
|
||||
requestId: string
|
||||
): Promise<import('./types').MovieRequest | null> {
|
||||
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<void> {
|
||||
await this.withRetry(() => this.adapter.hSet(this.movieRequestsKey(), request.id, JSON.stringify(request)));
|
||||
async createMovieRequest(
|
||||
request: import('./types').MovieRequest
|
||||
): Promise<void> {
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hSet(
|
||||
this.movieRequestsKey(),
|
||||
request.id,
|
||||
JSON.stringify(request)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async updateMovieRequest(requestId: string, updates: Partial<import('./types').MovieRequest>): Promise<void> {
|
||||
async updateMovieRequest(
|
||||
requestId: string,
|
||||
updates: Partial<import('./types').MovieRequest>
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
await this.withRetry(() => this.adapter.hDel(this.movieRequestsKey(), requestId));
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hDel(this.movieRequestsKey(), requestId)
|
||||
);
|
||||
}
|
||||
|
||||
async getUserMovieRequests(userName: string): Promise<string[]> {
|
||||
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<void> {
|
||||
await this.withRetry(() => this.adapter.sAdd(this.userMovieRequestsKey(userName), requestId));
|
||||
async addUserMovieRequest(
|
||||
userName: string,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
await this.withRetry(() =>
|
||||
this.adapter.sAdd(this.userMovieRequestsKey(userName), requestId)
|
||||
);
|
||||
}
|
||||
|
||||
async removeUserMovieRequest(userName: string, requestId: string): Promise<void> {
|
||||
await this.withRetry(() => this.adapter.sRem(this.userMovieRequestsKey(userName), requestId));
|
||||
async removeUserMovieRequest(
|
||||
userName: string,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async setEmailNotificationPreference(
|
||||
userName: string,
|
||||
enabled: boolean
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
// 保存token到用户信息
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hSet(this.userInfoKey(userName), 'tvboxSubscribeToken', token)
|
||||
this.adapter.hSet(
|
||||
this.userInfoKey(userName),
|
||||
'tvboxSubscribeToken',
|
||||
token
|
||||
)
|
||||
);
|
||||
|
||||
// 创建token到用户名的反向索引
|
||||
|
||||
+58
-14
@@ -42,6 +42,7 @@ export interface IStorage {
|
||||
): Promise<void>;
|
||||
getAllPlayRecords(userName: string): Promise<{ [key: string]: PlayRecord }>;
|
||||
deletePlayRecord(userName: string, key: string): Promise<void>;
|
||||
deletePlayRecords(userName: string, keys: string[]): Promise<void>;
|
||||
// 清理超出限制的旧播放记录
|
||||
cleanupOldPlayRecords(userName: string): Promise<void>;
|
||||
// 迁移播放记录
|
||||
@@ -58,7 +59,10 @@ export interface IStorage {
|
||||
// 音乐播放记录相关
|
||||
getMusicPlayRecord(userName: string, key: string): Promise<any | null>;
|
||||
setMusicPlayRecord(userName: string, key: string, record: any): Promise<void>;
|
||||
batchSetMusicPlayRecords(userName: string, records: { key: string; record: any }[]): Promise<void>;
|
||||
batchSetMusicPlayRecords(
|
||||
userName: string,
|
||||
records: { key: string; record: any }[]
|
||||
): Promise<void>;
|
||||
getAllMusicPlayRecords(userName: string): Promise<{ [key: string]: any }>;
|
||||
deleteMusicPlayRecord(userName: string, key: string): Promise<void>;
|
||||
clearAllMusicPlayRecords(userName: string): Promise<void>;
|
||||
@@ -79,27 +83,55 @@ export interface IStorage {
|
||||
|
||||
// 漫画书架相关
|
||||
getMangaShelf(userName: string, key: string): Promise<MangaShelfItem | null>;
|
||||
setMangaShelf(userName: string, key: string, item: MangaShelfItem): Promise<void>;
|
||||
getAllMangaShelf(userName: string): Promise<{ [key: string]: MangaShelfItem }>;
|
||||
setMangaShelf(
|
||||
userName: string,
|
||||
key: string,
|
||||
item: MangaShelfItem
|
||||
): Promise<void>;
|
||||
getAllMangaShelf(
|
||||
userName: string
|
||||
): Promise<{ [key: string]: MangaShelfItem }>;
|
||||
deleteMangaShelf(userName: string, key: string): Promise<void>;
|
||||
|
||||
// 漫画阅读历史相关
|
||||
getMangaReadRecord(userName: string, key: string): Promise<MangaReadRecord | null>;
|
||||
setMangaReadRecord(userName: string, key: string, record: MangaReadRecord): Promise<void>;
|
||||
getAllMangaReadRecords(userName: string): Promise<{ [key: string]: MangaReadRecord }>;
|
||||
getMangaReadRecord(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<MangaReadRecord | null>;
|
||||
setMangaReadRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: MangaReadRecord
|
||||
): Promise<void>;
|
||||
getAllMangaReadRecords(
|
||||
userName: string
|
||||
): Promise<{ [key: string]: MangaReadRecord }>;
|
||||
deleteMangaReadRecord(userName: string, key: string): Promise<void>;
|
||||
cleanupOldMangaReadRecords?(userName: string): Promise<void>;
|
||||
|
||||
// 电子书书架相关
|
||||
getBookShelf(userName: string, key: string): Promise<BookShelfItem | null>;
|
||||
setBookShelf(userName: string, key: string, item: BookShelfItem): Promise<void>;
|
||||
setBookShelf(
|
||||
userName: string,
|
||||
key: string,
|
||||
item: BookShelfItem
|
||||
): Promise<void>;
|
||||
getAllBookShelf(userName: string): Promise<{ [key: string]: BookShelfItem }>;
|
||||
deleteBookShelf(userName: string, key: string): Promise<void>;
|
||||
|
||||
// 电子书阅读历史相关
|
||||
getBookReadRecord(userName: string, key: string): Promise<BookReadRecord | null>;
|
||||
setBookReadRecord(userName: string, key: string, record: BookReadRecord): Promise<void>;
|
||||
getAllBookReadRecords(userName: string): Promise<{ [key: string]: BookReadRecord }>;
|
||||
getBookReadRecord(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<BookReadRecord | null>;
|
||||
setBookReadRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: BookReadRecord
|
||||
): Promise<void>;
|
||||
getAllBookReadRecords(
|
||||
userName: string
|
||||
): Promise<{ [key: string]: BookReadRecord }>;
|
||||
deleteBookReadRecord(userName: string, key: string): Promise<void>;
|
||||
cleanupOldBookReadRecords?(userName: string): Promise<void>;
|
||||
|
||||
@@ -146,7 +178,10 @@ export interface IStorage {
|
||||
// 通知相关
|
||||
getNotifications(userName: string): Promise<Notification[]>;
|
||||
addNotification(userName: string, notification: Notification): Promise<void>;
|
||||
markNotificationAsRead(userName: string, notificationId: string): Promise<void>;
|
||||
markNotificationAsRead(
|
||||
userName: string,
|
||||
notificationId: string
|
||||
): Promise<void>;
|
||||
deleteNotification(userName: string, notificationId: string): Promise<void>;
|
||||
clearAllNotifications(userName: string): Promise<void>;
|
||||
getUnreadNotificationCount(userName: string): Promise<number>;
|
||||
@@ -156,13 +191,19 @@ export interface IStorage {
|
||||
setLastFavoriteCheckTime(userName: string, timestamp: number): Promise<void>;
|
||||
|
||||
// 求片冷却时间
|
||||
updateLastMovieRequestTime?(userName: string, timestamp: number): Promise<void>;
|
||||
updateLastMovieRequestTime?(
|
||||
userName: string,
|
||||
timestamp: number
|
||||
): Promise<void>;
|
||||
|
||||
// 求片相关
|
||||
getAllMovieRequests(): Promise<MovieRequest[]>;
|
||||
getMovieRequest(requestId: string): Promise<MovieRequest | null>;
|
||||
createMovieRequest(request: MovieRequest): Promise<void>;
|
||||
updateMovieRequest(requestId: string, updates: Partial<MovieRequest>): Promise<void>;
|
||||
updateMovieRequest(
|
||||
requestId: string,
|
||||
updates: Partial<MovieRequest>
|
||||
): Promise<void>;
|
||||
deleteMovieRequest(requestId: string): Promise<void>;
|
||||
getUserMovieRequests(userName: string): Promise<string[]>;
|
||||
addUserMovieRequest(userName: string, requestId: string): Promise<void>;
|
||||
@@ -188,7 +229,10 @@ export interface IStorage {
|
||||
getUserEmail?(userName: string): Promise<string | null>;
|
||||
setUserEmail?(userName: string, email: string): Promise<void>;
|
||||
getEmailNotificationPreference?(userName: string): Promise<boolean>;
|
||||
setEmailNotificationPreference?(userName: string, enabled: boolean): Promise<void>;
|
||||
setEmailNotificationPreference?(
|
||||
userName: string,
|
||||
enabled: boolean
|
||||
): Promise<void>;
|
||||
|
||||
// TVBox订阅token相关
|
||||
getTvboxSubscribeToken?(userName: string): Promise<string | null>;
|
||||
|
||||
Reference in New Issue
Block a user