书架更新通知

This commit is contained in:
mtvpls
2026-04-18 00:48:17 +08:00
parent 7fb11f0ff7
commit 308650f771
16 changed files with 797 additions and 22 deletions
+327 -1
View File
@@ -6,13 +6,140 @@ import { checkAnimeSubscriptions } from '@/lib/anime-subscription';
import { getConfig, refineConfig } from '@/lib/config';
import { db, getStorage } from '@/lib/db';
import { EmailService } from '@/lib/email.service';
import { FavoriteUpdate,getBatchFavoriteUpdateEmailTemplate } from '@/lib/email.templates';
import {
FavoriteUpdate,
MangaShelfUpdate,
getBatchFavoriteUpdateEmailTemplate,
getBatchMangaUpdateEmailTemplate,
} from '@/lib/email.templates';
import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
import { refreshLiveChannels } from '@/lib/live';
import { MangaChapter, MangaShelfItem } from '@/lib/manga.types';
import { startOpenListRefresh } from '@/lib/openlist-refresh';
import { getSuwayomiConfig, loginWithSimpleAuth, SuwayomiClient } from '@/lib/suwayomi.client';
import { SearchResult } from '@/lib/types';
export const runtime = 'nodejs';
const MAX_INLINE_MANGA_COVERS = 3;
const MAX_INLINE_MANGA_COVER_BYTES = 350 * 1024;
const TARGET_INLINE_MANGA_COVER_WIDTH = 480;
function buildSuwayomiBasicAuthHeader(username: string, password: string): string {
return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
}
async function fetchMangaCoverAsDataUri(coverUrl?: string): Promise<string | undefined> {
if (!coverUrl) return undefined;
try {
let requestUrl = coverUrl;
let headers: HeadersInit | undefined;
if (!/^https?:\/\//i.test(coverUrl)) {
if (!coverUrl.startsWith('/api/manga/image?')) {
return undefined;
}
const config = await getSuwayomiConfig();
const parsedProxyUrl = new URL(`http://localhost${coverUrl}`);
const rawPath = parsedProxyUrl.searchParams.get('path')?.trim();
if (!rawPath) return undefined;
if (/^https?:\/\//i.test(rawPath)) {
const target = new URL(rawPath);
const base = new URL(config.serverBaseUrl);
if (target.origin !== base.origin) {
return undefined;
}
requestUrl = target.toString();
} else {
requestUrl = `${config.serverBaseUrl}${rawPath.startsWith('/') ? rawPath : `/${rawPath}`}`;
}
if (config.authMode === 'basic_auth') {
if (!config.username || !config.password) return undefined;
headers = new Headers({
Authorization: buildSuwayomiBasicAuthHeader(config.username, config.password),
});
} else if (config.authMode === 'simple_login') {
headers = new Headers({
Cookie: await loginWithSimpleAuth(config),
});
}
}
const response = await fetch(requestUrl, {
headers,
cache: 'no-store',
});
if (!response.ok) {
return undefined;
}
const contentType = response.headers.get('content-type') || 'image/jpeg';
if (!contentType.startsWith('image/')) {
return undefined;
}
let buffer = Buffer.from(await response.arrayBuffer());
if (!buffer.length) {
return undefined;
}
let finalContentType = contentType;
if (buffer.length > MAX_INLINE_MANGA_COVER_BYTES) {
const sharp = (await import('sharp')).default;
const transformer = sharp(buffer, { failOn: 'none' }).rotate().resize({
width: TARGET_INLINE_MANGA_COVER_WIDTH,
withoutEnlargement: true,
});
const metadata = await transformer.metadata();
if (metadata.hasAlpha) {
buffer = await transformer.png({
compressionLevel: 9,
palette: true,
quality: 80,
effort: 10,
}).toBuffer();
finalContentType = 'image/png';
} else {
const qualities = [72, 60, 48];
let compressed: Buffer | null = null;
for (const quality of qualities) {
const next = await sharp(buffer, { failOn: 'none' })
.rotate()
.resize({
width: TARGET_INLINE_MANGA_COVER_WIDTH,
withoutEnlargement: true,
})
.jpeg({
quality,
mozjpeg: true,
})
.toBuffer();
compressed = next;
if (next.length <= MAX_INLINE_MANGA_COVER_BYTES) {
break;
}
}
buffer = compressed || buffer;
finalContentType = 'image/jpeg';
}
}
if (buffer.length > MAX_INLINE_MANGA_COVER_BYTES) {
return undefined;
}
return `data:${finalContentType};base64,${buffer.toString('base64')}`;
} catch (error) {
console.warn('漫画封面转 base64 失败:', error);
return undefined;
}
}
// 内存中记录最后执行时间(毫秒时间戳)
let lastExecutionTime = 0;
@@ -200,6 +327,11 @@ async function refreshRecordAndFavorites() {
// 函数级缓存:key 为 `${source}+${id}`,值为 Promise<VideoDetail | null>
const detailCache = new Map<string, Promise<SearchResult | null>>();
const mangaDetailCache = new Map<
string,
Promise<{ chapters: MangaChapter[]; shelfItem: Partial<MangaShelfItem> } | null>
>();
const suwayomiClient = new SuwayomiClient();
// 获取详情 Promise(带缓存和错误处理)
const getDetail = async (
@@ -230,6 +362,55 @@ async function refreshRecordAndFavorites() {
return promise;
};
const getMangaDetail = async (
item: MangaShelfItem
): Promise<{ chapters: MangaChapter[]; shelfItem: Partial<MangaShelfItem> } | null> => {
const key = `${item.sourceId}+${item.mangaId}`;
let promise = mangaDetailCache.get(key);
if (!promise) {
promise = suwayomiClient
.getMangaDetail({
mangaId: item.mangaId,
sourceId: item.sourceId,
title: item.title,
cover: item.cover,
sourceName: item.sourceName,
description: item.description,
author: item.author,
status: item.status,
})
.then((detail) => {
const chapters = [...(detail.chapters || [])].sort((a, b) => {
const diff = (a.chapterNumber || 0) - (b.chapterNumber || 0);
if (diff !== 0) return diff;
return a.id.localeCompare(b.id);
});
const latestChapter = chapters[chapters.length - 1];
return {
chapters,
shelfItem: {
title: detail.title || item.title,
cover: detail.cover || item.cover,
description: detail.description || item.description,
author: detail.author || item.author,
status: detail.status || item.status,
latestChapterId: latestChapter?.id,
latestChapterName: latestChapter?.name,
latestChapterCount: chapters.length,
},
};
})
.catch((err) => {
console.error(`获取漫画详情失败 (${key}):`, err);
mangaDetailCache.delete(key);
return null;
});
mangaDetailCache.set(key, promise);
}
return promise;
};
// 处理单个用户的函数
const processUser = async (user: string) => {
console.log(`开始处理用户: ${user}`);
@@ -429,6 +610,151 @@ async function refreshRecordAndFavorites() {
} catch (err) {
console.error(`获取用户收藏失败 (${user}):`, err);
}
// 漫画书架
try {
const shelf = await db.getAllMangaShelf(user);
const totalShelfItems = Object.keys(shelf).length;
let processedShelfItems = 0;
const now = Date.now();
const mangaUpdates: MangaShelfUpdate[] = [];
let inlinedCoverCount = 0;
for (const [key, item] of Object.entries(shelf)) {
try {
const detail = await getMangaDetail(item);
if (!detail) {
continue;
}
const latestChapterCount = detail.chapters.length;
const previousChapterCount = item.latestChapterCount;
const latestChapterId = detail.shelfItem.latestChapterId;
const latestChapterName = detail.shelfItem.latestChapterName;
const baseItem: MangaShelfItem = {
...item,
...detail.shelfItem,
};
if (!latestChapterId || latestChapterCount <= 0) {
await db.saveMangaShelf(user, item.sourceId, item.mangaId, {
...baseItem,
unreadChapterCount: item.unreadChapterCount ?? 0,
});
processedShelfItems++;
continue;
}
// 首次为老数据补齐基线,不触发通知
if (!previousChapterCount || !item.latestChapterId) {
await db.saveMangaShelf(user, item.sourceId, item.mangaId, {
...baseItem,
latestChapterId,
latestChapterName,
latestChapterCount,
unreadChapterCount: item.unreadChapterCount ?? 0,
});
processedShelfItems++;
continue;
}
const addedChapters = latestChapterCount - previousChapterCount;
const hasNewChapters = addedChapters > 0 && latestChapterId !== item.latestChapterId;
const nextUnreadChapterCount = hasNewChapters
? Math.max((item.unreadChapterCount || 0) + addedChapters, 0)
: item.unreadChapterCount ?? 0;
const nextItem: MangaShelfItem = {
...baseItem,
latestChapterId,
latestChapterName,
latestChapterCount,
unreadChapterCount: nextUnreadChapterCount,
};
if (hasNewChapters) {
await storage.addNotification(user, {
id: `manga_update_${item.sourceId}_${item.mangaId}_${now}`,
type: 'manga_update',
title: '漫画更新',
message: `${item.title}》新增 ${addedChapters} 话,已更新至 ${latestChapterName || '最新章节'}`,
timestamp: now,
read: false,
metadata: {
sourceId: item.sourceId,
mangaId: item.mangaId,
title: item.title,
cover: detail.shelfItem.cover || item.cover,
sourceName: item.sourceName,
latestChapterId,
latestChapterName,
unreadChapterCount: nextUnreadChapterCount,
},
});
const inlineCover =
inlinedCoverCount < MAX_INLINE_MANGA_COVERS
? await fetchMangaCoverAsDataUri(detail.shelfItem.cover || item.cover)
: undefined;
if (inlineCover) {
inlinedCoverCount++;
}
mangaUpdates.push({
title: item.title,
previousChapterCount,
latestChapterCount,
latestChapterName,
url: `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/manga/detail?mangaId=${encodeURIComponent(item.mangaId)}&sourceId=${encodeURIComponent(item.sourceId)}&title=${encodeURIComponent(item.title)}&cover=${encodeURIComponent(detail.shelfItem.cover || item.cover || '')}&sourceName=${encodeURIComponent(item.sourceName)}`,
cover: inlineCover,
});
}
await db.saveMangaShelf(user, item.sourceId, item.mangaId, nextItem);
processedShelfItems++;
} catch (err) {
console.error(`处理漫画书架失败 (${key}):`, err);
}
}
console.log(`漫画书架处理完成: ${processedShelfItems}/${totalShelfItems}`);
if (mangaUpdates.length > 0) {
(async () => {
try {
const userEmail = storage.getUserEmail ? await storage.getUserEmail(user) : null;
const emailNotifications = storage.getEmailNotificationPreference
? await storage.getEmailNotificationPreference(user)
: false;
if (userEmail && emailNotifications) {
const config = await getConfig();
const emailConfig = config?.EmailConfig;
if (emailConfig?.enabled) {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
const siteName = config?.SiteConfig?.SiteName || 'MoonTVPlus';
await EmailService.send(emailConfig, {
to: userEmail,
subject: `漫画书架更新汇总 - ${mangaUpdates.length} 部漫画有新章节`,
html: getBatchMangaUpdateEmailTemplate(
user,
mangaUpdates,
siteUrl,
siteName
),
});
}
}
} catch (emailError) {
console.error(`发送漫画更新邮件失败 (${user}):`, emailError);
}
})().catch((err) => console.error(`漫画更新邮件异步任务失败 (${user}):`, err));
}
} catch (err) {
console.error(`获取用户漫画书架失败 (${user}):`, err);
}
};
// 分批并行处理用户,避免并发过高
+7 -5
View File
@@ -34,21 +34,23 @@ export async function GET(request: NextRequest) {
const config = await getSuwayomiConfig();
const upstreamUrl = resolveUpstreamUrl(config.serverBaseUrl, pathOrUrl);
const buildHeaders = async (forceRelogin: boolean) => {
const buildHeaders = async (
forceRelogin: boolean
): Promise<HeadersInit | undefined> => {
if (config.authMode === 'basic_auth') {
if (!config.username || !config.password) {
throw new Error('Suwayomi basic_auth 缺少用户名或密码');
}
return {
return new Headers({
Authorization: `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`,
};
});
}
if (config.authMode === 'simple_login') {
return {
return new Headers({
Cookie: await loginWithSimpleAuth(config, forceRelogin),
};
});
}
return undefined;
+60 -3
View File
@@ -3,7 +3,7 @@
import { ArrowDownWideNarrow, ArrowUpWideNarrow, BookOpen, Clock3 } from 'lucide-react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { deleteMangaShelf, getAllMangaReadRecords, getAllMangaShelf, saveMangaShelf } from '@/lib/db.client';
import { MangaChapter, MangaDetail, MangaReadRecord, MangaShelfItem } from '@/lib/manga.types';
@@ -76,6 +76,7 @@ export default function MangaDetailPage() {
const [history, setHistory] = useState<Record<string, MangaReadRecord>>({});
const [shelf, setShelf] = useState<Record<string, MangaShelfItem>>({});
const [descOrder, setDescOrder] = useState(true);
const clearedOnOpenRef = useRef<string | null>(null);
const key = `${sourceId}+${mangaId}`;
const currentRecord = history[key];
@@ -111,6 +112,45 @@ export default function MangaDetailPage() {
});
}, [detail?.chapters, descOrder]);
const chronologicalChapters = useMemo(() => {
const list = detail?.chapters || [];
return [...list].sort((a, b) => {
const diff = (a.chapterNumber || 0) - (b.chapterNumber || 0);
if (diff !== 0) return diff;
return a.id.localeCompare(b.id);
});
}, [detail?.chapters]);
const latestChapter = chronologicalChapters[chronologicalChapters.length - 1];
const unreadChapterCount = shelf[key]?.unreadChapterCount || 0;
const newChapterIds = useMemo(() => {
if (unreadChapterCount <= 0) return new Set<string>();
return new Set(chronologicalChapters.slice(-unreadChapterCount).map((chapter) => chapter.id));
}, [chronologicalChapters, unreadChapterCount]);
useEffect(() => {
const shelfItem = shelf[key];
if (!detail || !shelfItem || !latestChapter || (shelfItem.unreadChapterCount || 0) <= 0) {
return;
}
if (clearedOnOpenRef.current === key) {
return;
}
clearedOnOpenRef.current = key;
const nextItem: MangaShelfItem = {
...shelfItem,
latestChapterId: latestChapter.id,
latestChapterName: latestChapter.name,
latestChapterCount: chronologicalChapters.length,
unreadChapterCount: 0,
};
// 只后台清零,当前页保留进入时看到的更新提示,刷新后再消失。
saveMangaShelf(sourceId, mangaId, nextItem).catch(() => undefined);
}, [chronologicalChapters.length, detail, key, latestChapter, mangaId, shelf, sourceId]);
const toggleShelf = async () => {
if (!detail) return;
if (shelf[key]) {
@@ -135,6 +175,10 @@ export default function MangaDetailPage() {
status: detail.status,
lastChapterId: currentRecord?.chapterId,
lastChapterName: currentRecord?.chapterName,
latestChapterId: latestChapter?.id,
latestChapterName: latestChapter?.name,
latestChapterCount: chronologicalChapters.length,
unreadChapterCount: 0,
};
await saveMangaShelf(sourceId, mangaId, item);
setShelf((prev) => ({ ...prev, [key]: item }));
@@ -196,16 +240,29 @@ export default function MangaDetailPage() {
{descOrder ? <ArrowDownWideNarrow className='inline h-4 w-4' /> : <ArrowUpWideNarrow className='inline h-4 w-4' />} {descOrder ? '倒序' : '正序'}
</button>
</div>
{unreadChapterCount > 0 && latestChapter && (
<div className='mb-4 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-700 dark:border-sky-900/50 dark:bg-sky-950/30 dark:text-sky-300'>
{unreadChapterCount} {latestChapter.name}
</div>
)}
<div className='grid gap-3'>
{chapters.map((chapter) => {
const active = currentRecord?.chapterId === chapter.id;
const isNewChapter = newChapterIds.has(chapter.id);
return (
<Link
key={chapter.id}
href={chapterHref(chapter)}
className={`rounded-2xl border px-4 py-3 text-sm transition ${active ? 'border-sky-400 bg-sky-50 dark:bg-sky-950/30' : 'border-gray-200 hover:border-sky-300 dark:border-gray-700'}`}
className={`rounded-2xl border px-4 py-3 text-sm transition ${active ? 'border-sky-400 bg-sky-50 dark:bg-sky-950/30' : isNewChapter ? 'border-emerald-300 bg-emerald-50/80 dark:border-emerald-800 dark:bg-emerald-950/20' : 'border-gray-200 hover:border-sky-300 dark:border-gray-700'}`}
>
<div className='font-medium text-gray-900 dark:text-gray-100'>{chapter.name}</div>
<div className='flex items-center justify-between gap-3'>
<div className='font-medium text-gray-900 dark:text-gray-100'>{chapter.name}</div>
{isNewChapter && (
<span className='rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-300'>
NEW
</span>
)}
</div>
<div className='mt-1 text-xs text-gray-500'>
{formatChapterMeta(chapter)}
{active && currentRecord ? ` · 上次看到第 ${currentRecord.pageIndex + 1}` : ''}
+47 -2
View File
@@ -5,8 +5,8 @@ import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { type MouseEvent, useEffect, useMemo, useRef, useState } from 'react';
import { getAllMangaReadRecords, saveMangaReadRecord } from '@/lib/db.client';
import type { MangaChapter, MangaDetail, MangaReadRecord } from '@/lib/manga.types';
import { getAllMangaReadRecords, getAllMangaShelf, saveMangaReadRecord, saveMangaShelf } from '@/lib/db.client';
import type { MangaChapter, MangaDetail, MangaReadRecord, MangaShelfItem } from '@/lib/manga.types';
import { processImageUrl } from '@/lib/utils';
import ProxyImage from '@/components/ProxyImage';
@@ -478,6 +478,51 @@ export default function MangaReadPage() {
};
}, [chapterId, mangaId, readMode, sourceId]);
useEffect(() => {
if (!mangaId || !sourceId || !chapterId || !mangaDetail) return;
const key = `${sourceId}+${mangaId}`;
const orderedChapters = [...(mangaDetail.chapters || [])].sort((a, b) => {
const diff = (a.chapterNumber || 0) - (b.chapterNumber || 0);
if (diff !== 0) return diff;
return a.id.localeCompare(b.id);
});
const latestChapter = orderedChapters[orderedChapters.length - 1];
const currentChapterIndex = orderedChapters.findIndex((chapter) => chapter.id === chapterId);
const nextUnreadChapterCount =
currentChapterIndex >= 0
? Math.max(orderedChapters.length - currentChapterIndex - 1, 0)
: undefined;
getAllMangaShelf()
.then(async (shelf) => {
const item = shelf[key];
if (!item) return;
const nextItem: MangaShelfItem = {
...item,
lastChapterId: chapterId,
lastChapterName: chapterName,
latestChapterId: latestChapter?.id || item.latestChapterId,
latestChapterName: latestChapter?.name || item.latestChapterName,
latestChapterCount: orderedChapters.length || item.latestChapterCount,
unreadChapterCount: nextUnreadChapterCount,
};
const changed =
nextItem.lastChapterId !== item.lastChapterId ||
nextItem.lastChapterName !== item.lastChapterName ||
nextItem.latestChapterId !== item.latestChapterId ||
nextItem.latestChapterName !== item.latestChapterName ||
nextItem.latestChapterCount !== item.latestChapterCount ||
nextItem.unreadChapterCount !== item.unreadChapterCount;
if (!changed) return;
await saveMangaShelf(sourceId, mangaId, nextItem);
})
.catch(() => undefined);
}, [chapterId, chapterName, mangaDetail, mangaId, sourceId]);
const hideTransientUi = () => {
setControlsVisible(false);
setSettingsOpen(false);
+14 -2
View File
@@ -3,7 +3,7 @@
import { BookOpen } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { deleteMangaShelf, getAllMangaShelf } from '@/lib/db.client';
import { deleteMangaShelf, getAllMangaShelf, subscribeToDataUpdates } from '@/lib/db.client';
import { MangaShelfItem } from '@/lib/manga.types';
import MangaCard from '@/components/MangaCard';
@@ -32,10 +32,17 @@ export default function MangaShelfPage() {
const [loading, setLoading] = useState(true);
useEffect(() => {
const unsubscribe = subscribeToDataUpdates<Record<string, MangaShelfItem>>(
'mangaShelfUpdated',
setShelf
);
getAllMangaShelf()
.then(setShelf)
.catch(() => undefined)
.finally(() => setLoading(false));
return unsubscribe;
}, []);
const shelfList = useMemo(
@@ -71,7 +78,12 @@ export default function MangaShelfPage() {
<MangaCard
item={item}
href={`/manga/detail?mangaId=${item.mangaId}&sourceId=${item.sourceId}&title=${encodeURIComponent(item.title)}&cover=${encodeURIComponent(item.cover)}&sourceName=${encodeURIComponent(item.sourceName)}`}
subtitle={item.lastChapterName || item.author || item.status}
subtitle={
item.unreadChapterCount && item.unreadChapterCount > 0
? `更新至 ${item.latestChapterName || '最新章节'} · 新增 ${item.unreadChapterCount}`
: item.lastChapterName || item.author || item.status
}
updateCount={item.unreadChapterCount}
/>
<button
onClick={() => removeItem(item.sourceId, item.mangaId)}