新增电子书架

This commit is contained in:
mtvpls
2026-04-29 02:24:29 +08:00
parent 6d9afd8072
commit 0d99953e99
38 changed files with 3826 additions and 6 deletions
+18
View File
@@ -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'; // 邮件发送方式
+123
View File
@@ -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;
}
}
+89
View File
@@ -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));
}
+139
View File
@@ -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;
}
+43
View File
@@ -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
View File
@@ -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',
+1 -1
View File
@@ -631,7 +631,7 @@ if (typeof window !== 'undefined') {
/**
* 通用的 fetch 函数,处理 401 状态码自动跳转登录
*/
async function fetchWithAuth(
export async function fetchWithAuth(
url: string,
options?: RequestInit
): Promise<Response> {
+35
View File
@@ -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') {
+1
View File
@@ -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'];
+492
View File
@@ -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
View File
@@ -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',
+71
View File
@@ -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[]> {
// 从新版用户列表获取
+14
View File
@@ -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[]>;