新增电子书架
This commit is contained in:
@@ -279,6 +279,24 @@ export interface AdminConfig {
|
||||
SourceIds?: string[]; // 限制可用源
|
||||
MaxSources?: number; // 搜索时最多查询多少个源
|
||||
};
|
||||
OPDSConfig?: {
|
||||
Enabled: boolean; // 是否启用电子书馆
|
||||
Sources?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled?: boolean;
|
||||
authMode?: 'none' | 'basic' | 'header';
|
||||
username?: string;
|
||||
password?: string;
|
||||
headerName?: string;
|
||||
headerValue?: string;
|
||||
searchTemplate?: string;
|
||||
preferFormat?: Array<'epub' | 'pdf'>;
|
||||
language?: string;
|
||||
}>;
|
||||
CacheTTL?: number;
|
||||
};
|
||||
EmailConfig?: {
|
||||
enabled: boolean; // 是否启用邮件通知
|
||||
provider: 'smtp' | 'resend'; // 邮件发送方式
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
const DB_NAME = 'moontv_books_cache';
|
||||
const STORE_NAME = 'epub_files';
|
||||
const DB_VERSION = 1;
|
||||
const DEFAULT_CACHE_LIMIT = 500 * 1024 * 1024;
|
||||
|
||||
export interface CachedBookFile {
|
||||
key: string;
|
||||
sourceId: string;
|
||||
bookId: string;
|
||||
title: string;
|
||||
format: 'epub' | 'pdf';
|
||||
acquisitionHref: string;
|
||||
blob: Blob;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
updatedAt: number;
|
||||
lastOpenTime: number;
|
||||
}
|
||||
|
||||
function openDatabase(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' });
|
||||
store.createIndex('lastOpenTime', 'lastOpenTime', { unique: false });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('打开 IndexedDB 失败'));
|
||||
});
|
||||
}
|
||||
|
||||
export function buildBookCacheKey(sourceId: string, bookId: string, acquisitionHref: string) {
|
||||
return `${sourceId}::${bookId}::${acquisitionHref}`;
|
||||
}
|
||||
|
||||
export async function getCachedBookFile(key: string): Promise<CachedBookFile | null> {
|
||||
const db = await openDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.get(key);
|
||||
request.onsuccess = () => {
|
||||
db.close();
|
||||
resolve((request.result as CachedBookFile | undefined) || null);
|
||||
};
|
||||
request.onerror = () => {
|
||||
db.close();
|
||||
reject(request.error || new Error('读取缓存失败'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function putCachedBookFile(record: CachedBookFile): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).put(record);
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
tx.onerror = () => {
|
||||
db.close();
|
||||
reject(tx.error || new Error('写入缓存失败'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function touchCachedBookFile(key: string): Promise<void> {
|
||||
const current = await getCachedBookFile(key);
|
||||
if (!current) return;
|
||||
await putCachedBookFile({ ...current, lastOpenTime: Date.now() });
|
||||
}
|
||||
|
||||
export async function deleteCachedBookFile(key: string): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).delete(key);
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
tx.onerror = () => {
|
||||
db.close();
|
||||
reject(tx.error || new Error('删除缓存失败'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function listCachedBookFiles(): Promise<CachedBookFile[]> {
|
||||
const db = await openDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const request = tx.objectStore(STORE_NAME).getAll();
|
||||
request.onsuccess = () => {
|
||||
db.close();
|
||||
resolve((request.result as CachedBookFile[]) || []);
|
||||
};
|
||||
request.onerror = () => {
|
||||
db.close();
|
||||
reject(request.error || new Error('读取缓存列表失败'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function enforceBookCacheLimit(limit = DEFAULT_CACHE_LIMIT): Promise<void> {
|
||||
const items = await listCachedBookFiles();
|
||||
const total = items.reduce((sum, item) => sum + item.size, 0);
|
||||
if (total <= limit) return;
|
||||
let current = total;
|
||||
const sorted = [...items].sort((a, b) => a.lastOpenTime - b.lastOpenTime);
|
||||
for (const item of sorted) {
|
||||
if (current <= limit) break;
|
||||
await deleteCachedBookFile(item.key);
|
||||
current -= item.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { BookReadRecord, BookShelfItem } from './book.types';
|
||||
import { fetchWithAuth, generateStorageKey } from './db.client';
|
||||
|
||||
const BOOK_SHELF_KEY = 'moontv_book_shelf';
|
||||
const BOOK_HISTORY_KEY = 'moontv_book_history';
|
||||
const MAX_BOOK_HISTORY = 100;
|
||||
const MAX_BOOK_HISTORY_THRESHOLD = MAX_BOOK_HISTORY + 10;
|
||||
|
||||
function isRemoteStorage() {
|
||||
return ((window as Window & { RUNTIME_CONFIG?: { STORAGE_TYPE?: string } }).RUNTIME_CONFIG?.STORAGE_TYPE || process.env.STORAGE_TYPE || 'localstorage') !== 'localstorage';
|
||||
}
|
||||
|
||||
function trimRecords(records: Record<string, BookReadRecord>) {
|
||||
const entries = Object.entries(records);
|
||||
if (entries.length <= MAX_BOOK_HISTORY_THRESHOLD) return records;
|
||||
return Object.fromEntries(entries.sort(([, a], [, b]) => b.saveTime - a.saveTime).slice(0, MAX_BOOK_HISTORY));
|
||||
}
|
||||
|
||||
export async function getAllBookShelf(): Promise<Record<string, BookShelfItem>> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
if (isRemoteStorage()) {
|
||||
return (await (await fetchWithAuth('/api/books/shelf')).json()) as Record<string, BookShelfItem>;
|
||||
}
|
||||
const raw = localStorage.getItem(BOOK_SHELF_KEY);
|
||||
return raw ? (JSON.parse(raw) as Record<string, BookShelfItem>) : {};
|
||||
}
|
||||
|
||||
export async function saveBookShelf(sourceId: string, bookId: string, item: BookShelfItem): Promise<void> {
|
||||
const key = generateStorageKey(sourceId, bookId);
|
||||
if (isRemoteStorage()) {
|
||||
await fetchWithAuth('/api/books/shelf', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, item }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const data = await getAllBookShelf();
|
||||
data[key] = item;
|
||||
localStorage.setItem(BOOK_SHELF_KEY, JSON.stringify(data));
|
||||
}
|
||||
|
||||
export async function deleteBookShelf(sourceId: string, bookId: string): Promise<void> {
|
||||
const key = generateStorageKey(sourceId, bookId);
|
||||
if (isRemoteStorage()) {
|
||||
await fetchWithAuth(`/api/books/shelf?key=${encodeURIComponent(key)}`, { method: 'DELETE' });
|
||||
return;
|
||||
}
|
||||
const data = await getAllBookShelf();
|
||||
delete data[key];
|
||||
localStorage.setItem(BOOK_SHELF_KEY, JSON.stringify(data));
|
||||
}
|
||||
|
||||
export async function getAllBookReadRecords(): Promise<Record<string, BookReadRecord>> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
if (isRemoteStorage()) {
|
||||
return (await (await fetchWithAuth('/api/books/history')).json()) as Record<string, BookReadRecord>;
|
||||
}
|
||||
const raw = localStorage.getItem(BOOK_HISTORY_KEY);
|
||||
return raw ? (JSON.parse(raw) as Record<string, BookReadRecord>) : {};
|
||||
}
|
||||
|
||||
export async function saveBookReadRecord(sourceId: string, bookId: string, record: BookReadRecord): Promise<void> {
|
||||
const key = generateStorageKey(sourceId, bookId);
|
||||
if (isRemoteStorage()) {
|
||||
await fetchWithAuth('/api/books/history', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, record }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const data = await getAllBookReadRecords();
|
||||
data[key] = record;
|
||||
localStorage.setItem(BOOK_HISTORY_KEY, JSON.stringify(trimRecords(data)));
|
||||
}
|
||||
|
||||
export async function deleteBookReadRecord(sourceId: string, bookId: string): Promise<void> {
|
||||
const key = generateStorageKey(sourceId, bookId);
|
||||
if (isRemoteStorage()) {
|
||||
await fetchWithAuth(`/api/books/history?key=${encodeURIComponent(key)}`, { method: 'DELETE' });
|
||||
return;
|
||||
}
|
||||
const data = await getAllBookReadRecords();
|
||||
delete data[key];
|
||||
localStorage.setItem(BOOK_HISTORY_KEY, JSON.stringify(data));
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
export interface BookSourceCapabilities {
|
||||
searchSupported: boolean;
|
||||
catalogSupported: boolean;
|
||||
searchMode: 'opds' | 'template' | 'disabled';
|
||||
catalogMode: 'navigation' | 'acquisition' | 'flat' | 'disabled';
|
||||
acquisitionTypes: string[];
|
||||
lastCheckedAt?: number;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface BookSource {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled?: boolean;
|
||||
authMode?: 'none' | 'basic' | 'header';
|
||||
username?: string;
|
||||
password?: string;
|
||||
headerName?: string;
|
||||
headerValue?: string;
|
||||
searchTemplate?: string;
|
||||
preferFormat?: Array<'epub' | 'pdf'>;
|
||||
language?: string;
|
||||
capabilities?: BookSourceCapabilities;
|
||||
}
|
||||
|
||||
export interface BookAcquisitionLink {
|
||||
rel: string;
|
||||
type: string;
|
||||
href: string;
|
||||
title?: string;
|
||||
isIndirect?: boolean;
|
||||
}
|
||||
|
||||
export interface BookNavLink {
|
||||
title: string;
|
||||
href: string;
|
||||
rel?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface BookListItem {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
title: string;
|
||||
author?: string;
|
||||
cover?: string;
|
||||
summary?: string;
|
||||
language?: string;
|
||||
published?: string;
|
||||
updated?: string;
|
||||
tags?: string[];
|
||||
detailHref?: string;
|
||||
acquisitionLinks: BookAcquisitionLink[];
|
||||
}
|
||||
|
||||
export interface BookDetail extends BookListItem {
|
||||
publisher?: string;
|
||||
identifier?: string;
|
||||
series?: string;
|
||||
categories?: string[];
|
||||
navigation?: BookNavLink[];
|
||||
}
|
||||
|
||||
export interface BookCatalogResult {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
href: string;
|
||||
entries: BookListItem[];
|
||||
navigation: BookNavLink[];
|
||||
nextHref?: string;
|
||||
previousHref?: string;
|
||||
}
|
||||
|
||||
export interface BookSearchFailure {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface BookSearchResult {
|
||||
results: BookListItem[];
|
||||
failedSources: BookSearchFailure[];
|
||||
}
|
||||
|
||||
export interface BookLocator {
|
||||
type: 'epub-cfi' | 'pdf-page' | 'href';
|
||||
value: string;
|
||||
href?: string;
|
||||
chapterTitle?: string;
|
||||
}
|
||||
|
||||
export interface BookShelfItem {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
bookId: string;
|
||||
title: string;
|
||||
author?: string;
|
||||
cover?: string;
|
||||
format?: 'epub' | 'pdf';
|
||||
detailHref?: string;
|
||||
acquisitionHref?: string;
|
||||
progressPercent?: number;
|
||||
lastReadTime?: number;
|
||||
lastLocatorType?: BookLocator['type'];
|
||||
lastLocatorValue?: string;
|
||||
lastChapterTitle?: string;
|
||||
saveTime: number;
|
||||
}
|
||||
|
||||
export interface BookReadRecord {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
bookId: string;
|
||||
title: string;
|
||||
author?: string;
|
||||
cover?: string;
|
||||
format: 'epub' | 'pdf';
|
||||
detailHref?: string;
|
||||
acquisitionHref?: string;
|
||||
locator: BookLocator;
|
||||
progressPercent: number;
|
||||
chapterTitle?: string;
|
||||
chapterHref?: string;
|
||||
saveTime: number;
|
||||
}
|
||||
|
||||
export interface BookReadManifest {
|
||||
book: BookDetail;
|
||||
format: 'epub' | 'pdf';
|
||||
fileUrl: string;
|
||||
acquisitionHref?: string;
|
||||
cacheKey?: string;
|
||||
coverUrl?: string;
|
||||
lastRecord?: BookReadRecord | null;
|
||||
}
|
||||
@@ -663,6 +663,49 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
adminConfig.SuwayomiConfig.MaxSources = 10;
|
||||
}
|
||||
|
||||
if (!adminConfig.OPDSConfig) {
|
||||
adminConfig.OPDSConfig = {
|
||||
Enabled: process.env.OPDS_ENABLED === 'true',
|
||||
Sources: (() => {
|
||||
const json = process.env.OPDS_SOURCES_JSON;
|
||||
if (json) {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch {
|
||||
// ignore invalid env json
|
||||
}
|
||||
}
|
||||
|
||||
const envUrl = process.env.OPDS_URL || process.env.NEXT_PUBLIC_OPDS_URL;
|
||||
if (!envUrl) return [];
|
||||
|
||||
return [{
|
||||
id: 'default',
|
||||
name: process.env.OPDS_NAME || '默认书源',
|
||||
url: envUrl,
|
||||
enabled: true,
|
||||
authMode: (process.env.OPDS_AUTH_MODE as 'none' | 'basic' | 'header' | undefined) || 'none',
|
||||
username: process.env.OPDS_USERNAME || '',
|
||||
password: process.env.OPDS_PASSWORD || '',
|
||||
headerName: process.env.OPDS_HEADER_NAME || '',
|
||||
headerValue: process.env.OPDS_HEADER_VALUE || '',
|
||||
searchTemplate: process.env.OPDS_SEARCH_TEMPLATE || '',
|
||||
}];
|
||||
})(),
|
||||
CacheTTL: Number(process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
if (adminConfig.OPDSConfig.Enabled === undefined) {
|
||||
adminConfig.OPDSConfig.Enabled = false;
|
||||
}
|
||||
if (!Array.isArray(adminConfig.OPDSConfig.Sources)) {
|
||||
adminConfig.OPDSConfig.Sources = [];
|
||||
}
|
||||
if (adminConfig.OPDSConfig.CacheTTL === undefined || Number.isNaN(adminConfig.OPDSConfig.CacheTTL)) {
|
||||
adminConfig.OPDSConfig.CacheTTL = Number(process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000);
|
||||
}
|
||||
|
||||
if (!adminConfig.NetDiskConfig) {
|
||||
adminConfig.NetDiskConfig = {
|
||||
Quark: {
|
||||
|
||||
+247
-2
@@ -17,6 +17,7 @@ import {
|
||||
} from './types';
|
||||
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 { userInfoCache } from './user-cache';
|
||||
@@ -784,7 +785,7 @@ export class D1Storage implements IStorage {
|
||||
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
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(username, song_id) DO UPDATE SET
|
||||
source = excluded.source,
|
||||
songmid = excluded.songmid,
|
||||
@@ -2096,6 +2097,248 @@ export class D1Storage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ==================== 电子书书架 ====================
|
||||
|
||||
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();
|
||||
if (!result) return null;
|
||||
return {
|
||||
sourceId: result.source_id as string,
|
||||
sourceName: result.source_name as string,
|
||||
bookId: result.book_id as string,
|
||||
title: result.title as string,
|
||||
author: (result.author as string) || undefined,
|
||||
cover: (result.cover as string) || undefined,
|
||||
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,
|
||||
lastLocatorValue: (result.last_locator_value as string) || undefined,
|
||||
lastChapterTitle: (result.last_chapter_title as string) || undefined,
|
||||
saveTime: Number(result.save_time || 0),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getBookShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setBookShelf(userName: string, key: string, item: BookShelfItem): Promise<void> {
|
||||
try {
|
||||
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
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(username, key) DO UPDATE SET
|
||||
source_id = excluded.source_id,
|
||||
source_name = excluded.source_name,
|
||||
book_id = excluded.book_id,
|
||||
title = excluded.title,
|
||||
author = excluded.author,
|
||||
cover = excluded.cover,
|
||||
format = excluded.format,
|
||||
detail_href = excluded.detail_href,
|
||||
acquisition_href = excluded.acquisition_href,
|
||||
progress_percent = excluded.progress_percent,
|
||||
last_read_time = excluded.last_read_time,
|
||||
last_locator_type = excluded.last_locator_type,
|
||||
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();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.setBookShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
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 shelves: { [key: string]: BookShelfItem } = {};
|
||||
if (!results.results) return shelves;
|
||||
for (const row of results.results) {
|
||||
shelves[row.key as string] = {
|
||||
sourceId: row.source_id as string,
|
||||
sourceName: row.source_name as string,
|
||||
bookId: row.book_id as string,
|
||||
title: row.title as string,
|
||||
author: (row.author as string) || undefined,
|
||||
cover: (row.cover as string) || undefined,
|
||||
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,
|
||||
lastLocatorValue: (row.last_locator_value as string) || undefined,
|
||||
lastChapterTitle: (row.last_chapter_title as string) || undefined,
|
||||
saveTime: Number(row.save_time || 0),
|
||||
};
|
||||
}
|
||||
return shelves;
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getAllBookShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.deleteBookShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 电子书阅读历史 ====================
|
||||
|
||||
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();
|
||||
if (!result) return null;
|
||||
return {
|
||||
sourceId: result.source_id as string,
|
||||
sourceName: result.source_name as string,
|
||||
bookId: result.book_id as string,
|
||||
title: result.title as string,
|
||||
author: (result.author as string) || undefined,
|
||||
cover: (result.cover as string) || undefined,
|
||||
format: result.format as 'epub' | 'pdf',
|
||||
detailHref: (result.detail_href as string) || undefined,
|
||||
acquisitionHref: (result.acquisition_href as string) || undefined,
|
||||
locator: {
|
||||
type: result.locator_type as BookReadRecord['locator']['type'],
|
||||
value: result.locator_value as string,
|
||||
href: (result.chapter_href as string) || undefined,
|
||||
chapterTitle: (result.chapter_title as string) || undefined,
|
||||
},
|
||||
progressPercent: Number(result.progress_percent || 0),
|
||||
chapterTitle: (result.chapter_title as string) || undefined,
|
||||
chapterHref: (result.chapter_href as string) || undefined,
|
||||
saveTime: Number(result.save_time || 0),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getBookReadRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setBookReadRecord(userName: string, key: string, record: BookReadRecord): Promise<void> {
|
||||
try {
|
||||
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
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(username, key) DO UPDATE SET
|
||||
source_id = excluded.source_id,
|
||||
source_name = excluded.source_name,
|
||||
book_id = excluded.book_id,
|
||||
title = excluded.title,
|
||||
author = excluded.author,
|
||||
cover = excluded.cover,
|
||||
format = excluded.format,
|
||||
detail_href = excluded.detail_href,
|
||||
acquisition_href = excluded.acquisition_href,
|
||||
locator_type = excluded.locator_type,
|
||||
locator_value = excluded.locator_value,
|
||||
chapter_title = excluded.chapter_title,
|
||||
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();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.setBookReadRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
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 records: { [key: string]: BookReadRecord } = {};
|
||||
if (!results.results) return records;
|
||||
for (const row of results.results) {
|
||||
records[row.key as string] = {
|
||||
sourceId: row.source_id as string,
|
||||
sourceName: row.source_name as string,
|
||||
bookId: row.book_id as string,
|
||||
title: row.title as string,
|
||||
author: (row.author as string) || undefined,
|
||||
cover: (row.cover as string) || undefined,
|
||||
format: row.format as 'epub' | 'pdf',
|
||||
detailHref: (row.detail_href as string) || undefined,
|
||||
acquisitionHref: (row.acquisition_href as string) || undefined,
|
||||
locator: {
|
||||
type: row.locator_type as BookReadRecord['locator']['type'],
|
||||
value: row.locator_value as string,
|
||||
href: (row.chapter_href as string) || undefined,
|
||||
chapterTitle: (row.chapter_title as string) || undefined,
|
||||
},
|
||||
progressPercent: Number(row.progress_percent || 0),
|
||||
chapterTitle: (row.chapter_title as string) || undefined,
|
||||
chapterHref: (row.chapter_href as string) || undefined,
|
||||
saveTime: Number(row.save_time || 0),
|
||||
};
|
||||
}
|
||||
return records;
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getAllBookReadRecords error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.deleteBookReadRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupOldBookReadRecords(userName: string): Promise<void> {
|
||||
try {
|
||||
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 count = Number(countResult?.count || 0);
|
||||
if (count <= threshold) return;
|
||||
await this.db.prepare(`
|
||||
DELETE FROM book_read_records
|
||||
WHERE username = ?
|
||||
AND key NOT IN (
|
||||
SELECT key FROM book_read_records
|
||||
WHERE username = ?
|
||||
ORDER BY save_time DESC
|
||||
LIMIT ?
|
||||
)
|
||||
`).bind(userName, userName, maxRecords).run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.cleanupOldBookReadRecords error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 跳过配置 ====================
|
||||
|
||||
async getSkipConfig(userName: string, source: string, id: string): Promise<SkipConfig | null> {
|
||||
@@ -2373,7 +2616,7 @@ export class D1Storage implements IStorage {
|
||||
requested_by, request_count, status, created_at, updated_at,
|
||||
fulfilled_at, fulfilled_source, fulfilled_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
.bind(
|
||||
request.id,
|
||||
@@ -2558,6 +2801,8 @@ export class D1Storage implements IStorage {
|
||||
'search_history',
|
||||
'manga_shelf',
|
||||
'manga_read_records',
|
||||
'book_shelf',
|
||||
'book_read_records',
|
||||
'skip_configs',
|
||||
'music_play_records',
|
||||
'music_playlists',
|
||||
|
||||
@@ -631,7 +631,7 @@ if (typeof window !== 'undefined') {
|
||||
/**
|
||||
* 通用的 fetch 函数,处理 401 状态码自动跳转登录
|
||||
*/
|
||||
async function fetchWithAuth(
|
||||
export async function fetchWithAuth(
|
||||
url: string,
|
||||
options?: RequestInit
|
||||
): Promise<Response> {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AdminConfig } from './admin.types';
|
||||
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 { RedisStorage } from './redis.db';
|
||||
import { DanmakuFilterConfig,Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||
@@ -761,6 +762,40 @@ export class DbManager {
|
||||
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 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 }> {
|
||||
return this.storage.getAllBookShelf(userName);
|
||||
}
|
||||
|
||||
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 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 }> {
|
||||
return this.storage.getAllBookReadRecords(userName);
|
||||
}
|
||||
|
||||
async deleteBookReadRecord(userName: string, sourceId: string, bookId: string): Promise<void> {
|
||||
await this.storage.deleteBookReadRecord(userName, generateStorageKey(sourceId, bookId));
|
||||
}
|
||||
|
||||
// 获取全部用户名
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
if (typeof (this.storage as any).getAllUsers === 'function') {
|
||||
|
||||
@@ -12,6 +12,7 @@ export const FEATURE_PERMISSION_OPTIONS = [
|
||||
{ key: 'web_live', label: '网络直播', description: '网络直播观看' },
|
||||
{ key: 'music', label: '音乐', description: '音乐视听功能' },
|
||||
{ key: 'manga', label: '漫画展馆', description: '漫画搜索、阅读与书架' },
|
||||
{ key: 'books', label: '电子书馆', description: 'OPDS 电子书浏览、阅读与书架' },
|
||||
] as const;
|
||||
|
||||
export type FeaturePermissionKey = (typeof FEATURE_PERMISSION_OPTIONS)[number]['key'];
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { parseStringPromise } from 'xml2js';
|
||||
|
||||
import { getConfig } from './config';
|
||||
import {
|
||||
BookAcquisitionLink,
|
||||
BookCatalogResult,
|
||||
BookDetail,
|
||||
BookListItem,
|
||||
BookSearchFailure,
|
||||
BookSearchResult,
|
||||
BookSource,
|
||||
BookSourceCapabilities,
|
||||
} from './book.types';
|
||||
|
||||
interface ResolvedOPDSConfig {
|
||||
enabled: boolean;
|
||||
sources: BookSource[];
|
||||
cacheTTL: number;
|
||||
}
|
||||
|
||||
interface ParsedFeedLink {
|
||||
href: string;
|
||||
rel?: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface ParsedFeedEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
author?: string;
|
||||
summary?: string;
|
||||
content?: string;
|
||||
language?: string;
|
||||
published?: string;
|
||||
updated?: string;
|
||||
categories: string[];
|
||||
links: ParsedFeedLink[];
|
||||
}
|
||||
|
||||
interface ParsedFeed {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
id?: string;
|
||||
links: ParsedFeedLink[];
|
||||
entries: ParsedFeedEntry[];
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = Number(process.env.OPDS_TIMEOUT_MS || 20000);
|
||||
const feedCache = new Map<string, { expiresAt: number; data: ParsedFeed }>();
|
||||
const sourceCapabilityCache = new Map<string, { expiresAt: number; data: BookSourceCapabilities }>();
|
||||
|
||||
function asArray<T>(value: T | T[] | undefined | null): T[] {
|
||||
if (!value) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
function textValue(value: any): string {
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (typeof value === 'number') return String(value);
|
||||
if (value && typeof value._ === 'string') return value._.trim();
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeUrl(base: string, href?: string): string {
|
||||
if (!href) return base;
|
||||
return new URL(href, base).toString();
|
||||
}
|
||||
|
||||
function buildProxyUrl(sourceId: string, href: string): string {
|
||||
return `/api/books/file?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(href)}`;
|
||||
}
|
||||
|
||||
function mapFormat(type: string): 'epub' | 'pdf' | null {
|
||||
const lower = type.toLowerCase();
|
||||
if (lower.includes('epub')) return 'epub';
|
||||
if (lower.includes('pdf')) return 'pdf';
|
||||
return null;
|
||||
}
|
||||
|
||||
function isAcquisitionRel(rel?: string): boolean {
|
||||
return !!rel && rel.includes('opds-spec.org/acquisition');
|
||||
}
|
||||
|
||||
function isNavigationRel(rel?: string): boolean {
|
||||
return rel === 'subsection' || rel === 'collection' || rel === 'start';
|
||||
}
|
||||
|
||||
function isNavigationLink(link: ParsedFeedLink): boolean {
|
||||
const type = (link.type || '').toLowerCase();
|
||||
return isNavigationRel(link.rel) || type.includes('kind=navigation') || (type.includes('opds-catalog') && !isAcquisitionRel(link.rel));
|
||||
}
|
||||
|
||||
function pickCoverLink(links: ParsedFeedLink[]): string | undefined {
|
||||
const cover = links.find((link) => link.rel?.includes('image/thumbnail'))
|
||||
|| links.find((link) => link.rel?.includes('image'));
|
||||
return cover?.href;
|
||||
}
|
||||
|
||||
function pickDetailHref(links: ParsedFeedLink[]): string | undefined {
|
||||
const preferred = links.find((link) => link.rel === 'alternate' && (link.type || '').includes('atom+xml'))
|
||||
|| links.find((link) => link.rel === 'self' && (link.type || '').includes('atom+xml'))
|
||||
|| links.find((link) => isNavigationLink(link));
|
||||
return preferred?.href;
|
||||
}
|
||||
|
||||
function extractAcquisitionLinks(entry: ParsedFeedEntry): BookAcquisitionLink[] {
|
||||
return entry.links
|
||||
.filter((link) => isAcquisitionRel(link.rel) || mapFormat(link.type || '') !== null)
|
||||
.map((link) => ({
|
||||
rel: link.rel || 'http://opds-spec.org/acquisition',
|
||||
type: link.type || 'application/octet-stream',
|
||||
href: link.href,
|
||||
title: link.title,
|
||||
isIndirect: !!link.rel?.includes('indirect'),
|
||||
}));
|
||||
}
|
||||
|
||||
function isLikelyNavigationEntry(entry: ParsedFeedEntry): boolean {
|
||||
const hasAcquisition = extractAcquisitionLinks(entry).length > 0;
|
||||
const hasNavigationLink = entry.links.some((link) => isNavigationLink(link));
|
||||
return hasNavigationLink && !hasAcquisition;
|
||||
}
|
||||
|
||||
function mapEntryToItem(source: BookSource, entry: ParsedFeedEntry): BookListItem {
|
||||
const acquisitionLinks = extractAcquisitionLinks(entry);
|
||||
|
||||
return {
|
||||
id: entry.id || pickDetailHref(entry.links) || acquisitionLinks[0]?.href || entry.title,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
title: entry.title || '未命名电子书',
|
||||
author: entry.author,
|
||||
cover: (() => { const coverHref = pickCoverLink(entry.links); return coverHref ? buildProxyUrl(source.id, coverHref) : undefined; })(),
|
||||
summary: entry.summary || entry.content || undefined,
|
||||
language: entry.language,
|
||||
published: entry.published,
|
||||
updated: entry.updated,
|
||||
tags: entry.categories,
|
||||
detailHref: pickDetailHref(entry.links),
|
||||
acquisitionLinks,
|
||||
};
|
||||
}
|
||||
|
||||
function mapEntryToDetail(source: BookSource, entry: ParsedFeedEntry): BookDetail {
|
||||
const item = mapEntryToItem(source, entry);
|
||||
return {
|
||||
...item,
|
||||
categories: entry.categories,
|
||||
navigation: entry.links
|
||||
.filter((link) => isNavigationRel(link.rel))
|
||||
.map((link) => ({ title: link.title || entry.title, href: link.href, rel: link.rel, type: link.type })),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveOPDSConfig(): Promise<ResolvedOPDSConfig> {
|
||||
let enabled = process.env.OPDS_ENABLED === 'true';
|
||||
let sources: BookSource[] = [];
|
||||
const cacheTTL = Number(process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000);
|
||||
|
||||
const envJson = process.env.OPDS_SOURCES_JSON;
|
||||
if (envJson) {
|
||||
try {
|
||||
sources = JSON.parse(envJson) as BookSource[];
|
||||
} catch {
|
||||
// ignore invalid json
|
||||
}
|
||||
} else if (process.env.OPDS_URL || process.env.NEXT_PUBLIC_OPDS_URL) {
|
||||
sources = [{
|
||||
id: 'default',
|
||||
name: process.env.OPDS_NAME || '默认书源',
|
||||
url: process.env.OPDS_URL || process.env.NEXT_PUBLIC_OPDS_URL || '',
|
||||
authMode: (process.env.OPDS_AUTH_MODE as BookSource['authMode']) || 'none',
|
||||
username: process.env.OPDS_USERNAME || '',
|
||||
password: process.env.OPDS_PASSWORD || '',
|
||||
headerName: process.env.OPDS_HEADER_NAME || '',
|
||||
headerValue: process.env.OPDS_HEADER_VALUE || '',
|
||||
searchTemplate: process.env.OPDS_SEARCH_TEMPLATE || '',
|
||||
enabled: true,
|
||||
}];
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getConfig();
|
||||
if (config.OPDSConfig) {
|
||||
enabled = config.OPDSConfig.Enabled ?? enabled;
|
||||
if (Array.isArray(config.OPDSConfig.Sources) && config.OPDSConfig.Sources.length > 0) {
|
||||
sources = config.OPDSConfig.Sources as BookSource[];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore and fallback to env
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
cacheTTL,
|
||||
sources: (sources || []).filter((source) => !!source?.url && source.enabled !== false),
|
||||
};
|
||||
}
|
||||
|
||||
function buildHeaders(source: BookSource): HeadersInit {
|
||||
if (source.authMode === 'basic' && source.username) {
|
||||
return {
|
||||
Authorization: `Basic ${Buffer.from(`${source.username}:${source.password || ''}`).toString('base64')}`,
|
||||
};
|
||||
}
|
||||
if (source.authMode === 'header' && source.headerName && source.headerValue) {
|
||||
return {
|
||||
[source.headerName]: source.headerValue,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchText(url: string, headers: HeadersInit): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) throw new Error(`请求失败: ${response.status}`);
|
||||
return await response.text();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function parseLinks(value: any[], baseUrl: string): ParsedFeedLink[] {
|
||||
return value.map((item) => ({
|
||||
href: normalizeUrl(baseUrl, item?.$?.href),
|
||||
rel: item?.$?.rel,
|
||||
type: item?.$?.type,
|
||||
title: item?.$?.title,
|
||||
})).filter((item) => !!item.href);
|
||||
}
|
||||
|
||||
function parseEntries(value: any[], baseUrl: string): ParsedFeedEntry[] {
|
||||
return value.map((entry) => ({
|
||||
id: textValue(entry.id?.[0] || entry.id),
|
||||
title: textValue(entry.title?.[0] || entry.title),
|
||||
author: textValue(entry.author?.[0]?.name?.[0] || entry.author?.[0]?.name || entry.author?.name),
|
||||
summary: textValue(entry.summary?.[0] || entry.summary),
|
||||
content: textValue(entry.content?.[0] || entry.content),
|
||||
language: textValue(entry.language?.[0] || entry['dc:language']?.[0]),
|
||||
published: textValue(entry.published?.[0] || entry['dc:issued']?.[0]),
|
||||
updated: textValue(entry.updated?.[0]),
|
||||
categories: asArray(entry.category).map((item) => item?.$?.label || item?.$?.term).filter(Boolean),
|
||||
links: parseLinks(asArray(entry.link), baseUrl),
|
||||
}));
|
||||
}
|
||||
|
||||
async function parseFeed(xml: string, baseUrl: string): Promise<ParsedFeed> {
|
||||
const parsed = await parseStringPromise(xml, { explicitArray: true, trim: true });
|
||||
const feed = parsed.feed || parsed.entry;
|
||||
if (!feed) throw new Error('无法解析 OPDS feed');
|
||||
|
||||
const feedNode = parsed.feed ? feed : { entry: [feed] };
|
||||
return {
|
||||
title: textValue(feedNode.title?.[0] || '电子书目录'),
|
||||
subtitle: textValue(feedNode.subtitle?.[0] || ''),
|
||||
id: textValue(feedNode.id?.[0] || ''),
|
||||
links: parseLinks(asArray(feedNode.link), baseUrl),
|
||||
entries: parseEntries(asArray(feedNode.entry), baseUrl),
|
||||
};
|
||||
}
|
||||
|
||||
async function getFeed(source: BookSource, href?: string): Promise<ParsedFeed> {
|
||||
const target = normalizeUrl(source.url, href || source.url);
|
||||
const cacheKey = `${source.id}|${target}`;
|
||||
const cached = feedCache.get(cacheKey);
|
||||
const { cacheTTL } = await resolveOPDSConfig();
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.data;
|
||||
|
||||
const xml = await fetchText(target, buildHeaders(source));
|
||||
const data = await parseFeed(xml, target);
|
||||
feedCache.set(cacheKey, { data, expiresAt: Date.now() + cacheTTL });
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getSourceById(sourceId: string): Promise<BookSource> {
|
||||
const config = await resolveOPDSConfig();
|
||||
const source = config.sources.find((item) => item.id === sourceId);
|
||||
if (!source) throw new Error('未找到对应的 OPDS 书源');
|
||||
return source;
|
||||
}
|
||||
|
||||
async function detectCapabilities(source: BookSource): Promise<BookSourceCapabilities> {
|
||||
const cached = sourceCapabilityCache.get(source.id);
|
||||
const { cacheTTL } = await resolveOPDSConfig();
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.data;
|
||||
|
||||
try {
|
||||
const feed = await getFeed(source);
|
||||
const searchLink = feed.links.find((link) => link.rel === 'search');
|
||||
const navigationEntries = feed.entries.filter((entry) => isLikelyNavigationEntry(entry));
|
||||
const bookEntries = feed.entries.filter((entry) => !isLikelyNavigationEntry(entry));
|
||||
const acquisitionTypes = Array.from(new Set(bookEntries.flatMap((entry) => entry.links
|
||||
.map((link) => mapFormat(link.type || ''))
|
||||
.filter(Boolean) as string[])));
|
||||
const navigationCount = feed.links.filter((link) => isNavigationRel(link.rel)).length + navigationEntries.length;
|
||||
const entryCount = bookEntries.length;
|
||||
|
||||
const data: BookSourceCapabilities = {
|
||||
searchSupported: !!searchLink || !!source.searchTemplate,
|
||||
catalogSupported: navigationCount > 0 || entryCount > 0,
|
||||
searchMode: searchLink ? 'opds' : source.searchTemplate ? 'template' : 'disabled',
|
||||
catalogMode: navigationCount > 0 ? 'navigation' : entryCount > 0 ? 'flat' : 'disabled',
|
||||
acquisitionTypes,
|
||||
lastCheckedAt: Date.now(),
|
||||
};
|
||||
|
||||
sourceCapabilityCache.set(source.id, { data, expiresAt: Date.now() + cacheTTL });
|
||||
return data;
|
||||
} catch (error) {
|
||||
const data: BookSourceCapabilities = {
|
||||
searchSupported: !!source.searchTemplate,
|
||||
catalogSupported: false,
|
||||
searchMode: source.searchTemplate ? 'template' : 'disabled',
|
||||
catalogMode: 'disabled',
|
||||
acquisitionTypes: [],
|
||||
lastCheckedAt: Date.now(),
|
||||
lastError: (error as Error).message,
|
||||
};
|
||||
sourceCapabilityCache.set(source.id, { data, expiresAt: Date.now() + cacheTTL / 2 });
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOPDSConfig() {
|
||||
return resolveOPDSConfig();
|
||||
}
|
||||
|
||||
export class OPDSClient {
|
||||
async getSources(): Promise<BookSource[]> {
|
||||
const config = await resolveOPDSConfig();
|
||||
if (!config.enabled) return [];
|
||||
const withCapabilities = await Promise.all(config.sources.map(async (source) => ({
|
||||
...source,
|
||||
capabilities: await detectCapabilities(source),
|
||||
})));
|
||||
return withCapabilities;
|
||||
}
|
||||
|
||||
async getCatalog(sourceId: string, href?: string): Promise<BookCatalogResult> {
|
||||
const source = await getSourceById(sourceId);
|
||||
return this.getCatalogFromSource(source, href);
|
||||
}
|
||||
|
||||
async getCatalogFromSource(source: BookSource, href?: string): Promise<BookCatalogResult & { searchHref?: string }> {
|
||||
const feed = await getFeed(source, href);
|
||||
const navigationEntries = feed.entries.filter((entry) => isLikelyNavigationEntry(entry));
|
||||
const bookEntries = feed.entries.filter((entry) => !isLikelyNavigationEntry(entry));
|
||||
return {
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
title: feed.title,
|
||||
subtitle: feed.subtitle,
|
||||
href: normalizeUrl(source.url, href || source.url),
|
||||
entries: bookEntries.map((entry) => mapEntryToItem(source, entry)),
|
||||
navigation: [
|
||||
...feed.links.filter((link) => isNavigationLink(link)).map((link) => ({
|
||||
title: link.title || '目录',
|
||||
href: link.href,
|
||||
rel: link.rel,
|
||||
type: link.type,
|
||||
})),
|
||||
...navigationEntries
|
||||
.map((entry) => ({
|
||||
title: entry.title,
|
||||
href: pickDetailHref(entry.links) || entry.links.find((link) => isNavigationLink(link))?.href || '',
|
||||
rel: entry.links.find((link) => isNavigationLink(link))?.rel,
|
||||
type: entry.links.find((link) => isNavigationLink(link))?.type,
|
||||
}))
|
||||
.filter((item) => !!item.href),
|
||||
],
|
||||
nextHref: feed.links.find((link) => link.rel === 'next')?.href,
|
||||
previousHref: feed.links.find((link) => link.rel === 'previous')?.href,
|
||||
searchHref: feed.links.find((link) => link.rel === 'search')?.href,
|
||||
};
|
||||
}
|
||||
|
||||
async searchBooks(q: string, sourceId?: string): Promise<BookSearchResult> {
|
||||
const sources = sourceId ? [await getSourceById(sourceId)] : await this.getSources();
|
||||
const results: BookListItem[] = [];
|
||||
const failedSources: BookSearchFailure[] = [];
|
||||
|
||||
await Promise.all(sources.map(async (source) => {
|
||||
try {
|
||||
const capabilities = source.capabilities || await detectCapabilities(source);
|
||||
if (!capabilities.searchSupported) {
|
||||
failedSources.push({ sourceId: source.id, sourceName: source.name, error: '该书源不支持搜索' });
|
||||
return;
|
||||
}
|
||||
|
||||
let targetUrl = '';
|
||||
if (capabilities.searchMode === 'opds') {
|
||||
const rootFeed = await getFeed(source);
|
||||
const searchLink = rootFeed.links.find((link) => link.rel === 'search');
|
||||
if (!searchLink?.href) throw new Error('未找到 search link');
|
||||
targetUrl = searchLink.href.includes('{searchTerms}')
|
||||
? searchLink.href.replace('{searchTerms}', encodeURIComponent(q))
|
||||
: `${searchLink.href}${searchLink.href.includes('?') ? '&' : '?'}q=${encodeURIComponent(q)}`;
|
||||
} else if (source.searchTemplate) {
|
||||
targetUrl = source.searchTemplate.replace('{searchTerms}', encodeURIComponent(q));
|
||||
}
|
||||
|
||||
if (!targetUrl) throw new Error('未配置可用的搜索地址');
|
||||
const feed = await getFeed(source, targetUrl);
|
||||
results.push(...feed.entries.map((entry) => mapEntryToItem(source, entry)));
|
||||
} catch (error) {
|
||||
failedSources.push({ sourceId: source.id, sourceName: source.name, error: (error as Error).message });
|
||||
}
|
||||
}));
|
||||
|
||||
return { results, failedSources };
|
||||
}
|
||||
|
||||
async getBookDetail(sourceId: string, href: string, fallback?: Partial<BookDetail>): Promise<BookDetail> {
|
||||
const source = await getSourceById(sourceId);
|
||||
if (!href) {
|
||||
if (!fallback?.title) throw new Error('缺少详情链接');
|
||||
return {
|
||||
id: fallback.id || `${sourceId}:${fallback.title}`,
|
||||
sourceId,
|
||||
sourceName: source.name,
|
||||
title: fallback.title,
|
||||
author: fallback.author,
|
||||
cover: fallback.cover,
|
||||
summary: fallback.summary,
|
||||
acquisitionLinks: fallback.acquisitionLinks || [],
|
||||
detailHref: fallback.detailHref,
|
||||
tags: fallback.tags,
|
||||
categories: fallback.categories,
|
||||
navigation: fallback.navigation || [],
|
||||
} as BookDetail;
|
||||
}
|
||||
|
||||
const feed = await getFeed(source, href);
|
||||
const entry = feed.entries[0];
|
||||
if (!entry) {
|
||||
if (fallback?.title) {
|
||||
return {
|
||||
id: fallback.id || href,
|
||||
sourceId,
|
||||
sourceName: source.name,
|
||||
title: fallback.title,
|
||||
author: fallback.author,
|
||||
cover: fallback.cover,
|
||||
summary: fallback.summary,
|
||||
acquisitionLinks: fallback.acquisitionLinks || [],
|
||||
detailHref: href,
|
||||
tags: fallback.tags,
|
||||
categories: fallback.categories,
|
||||
navigation: fallback.navigation || [],
|
||||
} as BookDetail;
|
||||
}
|
||||
throw new Error('详情页没有可用书籍条目');
|
||||
}
|
||||
|
||||
const detail = mapEntryToDetail(source, entry);
|
||||
return {
|
||||
...detail,
|
||||
detailHref: href,
|
||||
summary: detail.summary || feed.subtitle || fallback?.summary,
|
||||
acquisitionLinks: detail.acquisitionLinks.length > 0 ? detail.acquisitionLinks : fallback?.acquisitionLinks || [],
|
||||
cover: detail.cover || fallback?.cover,
|
||||
};
|
||||
}
|
||||
|
||||
async getPreferredAcquisition(sourceId: string, href: string): Promise<{ format: 'epub' | 'pdf'; href: string }> {
|
||||
const detail = await this.getBookDetail(sourceId, href);
|
||||
const preferred = detail.acquisitionLinks
|
||||
.map((item) => ({ ...item, format: mapFormat(item.type || '') }))
|
||||
.find((item) => item.format === 'epub' || item.format === 'pdf');
|
||||
if (!preferred?.format) {
|
||||
throw new Error('当前书籍没有可在线阅读的 EPUB/PDF 资源');
|
||||
}
|
||||
return { format: preferred.format, href: preferred.href };
|
||||
}
|
||||
|
||||
async getSourceById(sourceId: string): Promise<BookSource> {
|
||||
return getSourceById(sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
export const opdsClient = new OPDSClient();
|
||||
export { buildProxyUrl };
|
||||
+272
-2
@@ -19,6 +19,7 @@ import {
|
||||
} from './types';
|
||||
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';
|
||||
|
||||
@@ -1439,7 +1440,7 @@ export class PostgresStorage implements IStorage {
|
||||
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
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
ON CONFLICT(username, song_id) DO UPDATE SET
|
||||
source = EXCLUDED.source,
|
||||
songmid = EXCLUDED.songmid,
|
||||
@@ -2065,6 +2066,273 @@ export class PostgresStorage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ==================== 电子书书架 ====================
|
||||
|
||||
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')
|
||||
.bind(userName, key)
|
||||
.first();
|
||||
|
||||
if (!result) return null;
|
||||
return {
|
||||
sourceId: result.source_id as string,
|
||||
sourceName: result.source_name as string,
|
||||
bookId: result.book_id as string,
|
||||
title: result.title as string,
|
||||
author: (result.author as string) || undefined,
|
||||
cover: (result.cover as string) || undefined,
|
||||
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,
|
||||
lastLocatorValue: (result.last_locator_value as string) || undefined,
|
||||
lastChapterTitle: (result.last_chapter_title as string) || undefined,
|
||||
saveTime: Number(result.save_time || 0),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.getBookShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setBookShelf(userName: string, key: string, item: BookShelfItem): Promise<void> {
|
||||
try {
|
||||
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
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
ON CONFLICT (username, key) DO UPDATE SET
|
||||
source_id = EXCLUDED.source_id,
|
||||
source_name = EXCLUDED.source_name,
|
||||
book_id = EXCLUDED.book_id,
|
||||
title = EXCLUDED.title,
|
||||
author = EXCLUDED.author,
|
||||
cover = EXCLUDED.cover,
|
||||
format = EXCLUDED.format,
|
||||
detail_href = EXCLUDED.detail_href,
|
||||
acquisition_href = EXCLUDED.acquisition_href,
|
||||
progress_percent = EXCLUDED.progress_percent,
|
||||
last_read_time = EXCLUDED.last_read_time,
|
||||
last_locator_type = EXCLUDED.last_locator_type,
|
||||
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();
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.setBookShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
.bind(userName)
|
||||
.all();
|
||||
const shelves: { [key: string]: BookShelfItem } = {};
|
||||
if (!results.results) return shelves;
|
||||
for (const row of results.results) {
|
||||
shelves[row.key as string] = {
|
||||
sourceId: row.source_id as string,
|
||||
sourceName: row.source_name as string,
|
||||
bookId: row.book_id as string,
|
||||
title: row.title as string,
|
||||
author: (row.author as string) || undefined,
|
||||
cover: (row.cover as string) || undefined,
|
||||
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,
|
||||
lastLocatorValue: (row.last_locator_value as string) || undefined,
|
||||
lastChapterTitle: (row.last_chapter_title as string) || undefined,
|
||||
saveTime: Number(row.save_time || 0),
|
||||
};
|
||||
}
|
||||
return shelves;
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.getAllBookShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.deleteBookShelf error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 电子书阅读历史 ====================
|
||||
|
||||
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')
|
||||
.bind(userName, key)
|
||||
.first();
|
||||
if (!result) return null;
|
||||
return {
|
||||
sourceId: result.source_id as string,
|
||||
sourceName: result.source_name as string,
|
||||
bookId: result.book_id as string,
|
||||
title: result.title as string,
|
||||
author: (result.author as string) || undefined,
|
||||
cover: (result.cover as string) || undefined,
|
||||
format: result.format as 'epub' | 'pdf',
|
||||
detailHref: (result.detail_href as string) || undefined,
|
||||
acquisitionHref: (result.acquisition_href as string) || undefined,
|
||||
locator: {
|
||||
type: result.locator_type as BookReadRecord['locator']['type'],
|
||||
value: result.locator_value as string,
|
||||
href: (result.chapter_href as string) || undefined,
|
||||
chapterTitle: (result.chapter_title as string) || undefined,
|
||||
},
|
||||
progressPercent: Number(result.progress_percent || 0),
|
||||
chapterTitle: (result.chapter_title as string) || undefined,
|
||||
chapterHref: (result.chapter_href as string) || undefined,
|
||||
saveTime: Number(result.save_time || 0),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.getBookReadRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setBookReadRecord(userName: string, key: string, record: BookReadRecord): Promise<void> {
|
||||
try {
|
||||
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
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
ON CONFLICT (username, key) DO UPDATE SET
|
||||
source_id = EXCLUDED.source_id,
|
||||
source_name = EXCLUDED.source_name,
|
||||
book_id = EXCLUDED.book_id,
|
||||
title = EXCLUDED.title,
|
||||
author = EXCLUDED.author,
|
||||
cover = EXCLUDED.cover,
|
||||
format = EXCLUDED.format,
|
||||
detail_href = EXCLUDED.detail_href,
|
||||
acquisition_href = EXCLUDED.acquisition_href,
|
||||
locator_type = EXCLUDED.locator_type,
|
||||
locator_value = EXCLUDED.locator_value,
|
||||
chapter_title = EXCLUDED.chapter_title,
|
||||
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();
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.setBookReadRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
.bind(userName)
|
||||
.all();
|
||||
const records: { [key: string]: BookReadRecord } = {};
|
||||
if (!results.results) return records;
|
||||
for (const row of results.results) {
|
||||
records[row.key as string] = {
|
||||
sourceId: row.source_id as string,
|
||||
sourceName: row.source_name as string,
|
||||
bookId: row.book_id as string,
|
||||
title: row.title as string,
|
||||
author: (row.author as string) || undefined,
|
||||
cover: (row.cover as string) || undefined,
|
||||
format: row.format as 'epub' | 'pdf',
|
||||
detailHref: (row.detail_href as string) || undefined,
|
||||
acquisitionHref: (row.acquisition_href as string) || undefined,
|
||||
locator: {
|
||||
type: row.locator_type as BookReadRecord['locator']['type'],
|
||||
value: row.locator_value as string,
|
||||
href: (row.chapter_href as string) || undefined,
|
||||
chapterTitle: (row.chapter_title as string) || undefined,
|
||||
},
|
||||
progressPercent: Number(row.progress_percent || 0),
|
||||
chapterTitle: (row.chapter_title as string) || undefined,
|
||||
chapterHref: (row.chapter_href as string) || undefined,
|
||||
saveTime: Number(row.save_time || 0),
|
||||
};
|
||||
}
|
||||
return records;
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.getAllBookReadRecords error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.deleteBookReadRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupOldBookReadRecords(userName: string): Promise<void> {
|
||||
try {
|
||||
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')
|
||||
.bind(userName)
|
||||
.first();
|
||||
const count = Number(countResult?.count || 0);
|
||||
if (count <= threshold) return;
|
||||
await this.db
|
||||
.prepare(`
|
||||
DELETE FROM book_read_records
|
||||
WHERE username = $1
|
||||
AND key NOT IN (
|
||||
SELECT key FROM book_read_records
|
||||
WHERE username = $1
|
||||
ORDER BY save_time DESC
|
||||
LIMIT $2
|
||||
)
|
||||
`)
|
||||
.bind(userName, maxRecords)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('PostgresStorage.cleanupOldBookReadRecords error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 跳过配置 ====================
|
||||
|
||||
async getSkipConfig(userName: string, source: string, id: string): Promise<SkipConfig | null> {
|
||||
@@ -2339,7 +2607,7 @@ export class PostgresStorage implements IStorage {
|
||||
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)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
`)
|
||||
.bind(
|
||||
request.id,
|
||||
@@ -2525,6 +2793,8 @@ export class PostgresStorage implements IStorage {
|
||||
'search_history',
|
||||
'manga_shelf',
|
||||
'manga_read_records',
|
||||
'book_shelf',
|
||||
'book_read_records',
|
||||
'skip_configs',
|
||||
'music_play_records',
|
||||
'music_playlists',
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { RedisAdapter } from './redis-adapter';
|
||||
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||
@@ -1569,6 +1570,76 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------- 电子书书架 ----------
|
||||
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));
|
||||
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 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;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteBookShelf(userName: string, key: string): Promise<void> {
|
||||
await this.withRetry(() => this.adapter.hDel(this.bookShelfHashKey(userName), key));
|
||||
}
|
||||
|
||||
// ---------- 电子书阅读历史 ----------
|
||||
private bookReadHashKey(user: string) {
|
||||
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));
|
||||
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 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;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteBookReadRecord(userName: string, key: string): Promise<void> {
|
||||
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 threshold = maxRecords + 10;
|
||||
if (Object.keys(records).length <= threshold) return;
|
||||
const keys = Object.entries(records)
|
||||
.sort(([, a], [, b]) => b.saveTime - a.saveTime)
|
||||
.slice(maxRecords)
|
||||
.map(([key]) => key);
|
||||
|
||||
if (keys.length > 0) {
|
||||
await this.withRetry(() => this.adapter.hDel(this.bookReadHashKey(userName), ...keys));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 获取全部用户 ----------
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
// 从新版用户列表获取
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { MangaReadRecord, MangaShelfItem } from './manga.types';
|
||||
import { BookReadRecord, BookShelfItem } from './book.types';
|
||||
|
||||
// 播放记录数据结构
|
||||
export interface PlayRecord {
|
||||
@@ -89,6 +90,19 @@ export interface IStorage {
|
||||
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>;
|
||||
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 }>;
|
||||
deleteBookReadRecord(userName: string, key: string): Promise<void>;
|
||||
cleanupOldBookReadRecords?(userName: string): Promise<void>;
|
||||
|
||||
// 用户列表
|
||||
getAllUsers(): Promise<string[]>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user