diff --git a/migrations/006_manga.sql b/migrations/006_manga.sql index 941636c..5257b5a 100644 --- a/migrations/006_manga.sql +++ b/migrations/006_manga.sql @@ -12,6 +12,10 @@ CREATE TABLE IF NOT EXISTS manga_shelf ( status TEXT, last_chapter_id TEXT, last_chapter_name TEXT, + latest_chapter_id TEXT, + latest_chapter_name TEXT, + latest_chapter_count INTEGER, + unread_chapter_count INTEGER, PRIMARY KEY (username, key), FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE ); diff --git a/migrations/postgres/006_manga.sql b/migrations/postgres/006_manga.sql index d4af337..0e540bf 100644 --- a/migrations/postgres/006_manga.sql +++ b/migrations/postgres/006_manga.sql @@ -12,12 +12,21 @@ CREATE TABLE IF NOT EXISTS manga_shelf ( status TEXT, last_chapter_id TEXT, last_chapter_name TEXT, + latest_chapter_id TEXT, + latest_chapter_name TEXT, + latest_chapter_count INTEGER, + unread_chapter_count INTEGER, PRIMARY KEY (username, key), FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_manga_shelf_user_time ON manga_shelf(username, save_time DESC); +ALTER TABLE manga_shelf ADD COLUMN IF NOT EXISTS latest_chapter_id TEXT; +ALTER TABLE manga_shelf ADD COLUMN IF NOT EXISTS latest_chapter_name TEXT; +ALTER TABLE manga_shelf ADD COLUMN IF NOT EXISTS latest_chapter_count INTEGER; +ALTER TABLE manga_shelf ADD COLUMN IF NOT EXISTS unread_chapter_count INTEGER; + CREATE TABLE IF NOT EXISTS manga_read_records ( username TEXT NOT NULL, key TEXT NOT NULL, diff --git a/package.json b/package.json index ba29782..53d53e5 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "redis": "^4.6.7", "remark-gfm": "^3.0.1", "server-only": "^0.0.1", + "sharp": "^0.34.5", "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", "swiper": "^11.2.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eee58c4..907ca5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: server-only: specifier: ^0.0.1 version: 0.0.1 + sharp: + specifier: ^0.34.5 + version: 0.34.5 socket.io: specifier: ^4.8.1 version: 4.8.3(bufferutil@4.1.0) diff --git a/src/app/api/cron/[password]/route.ts b/src/app/api/cron/[password]/route.ts index 6203153..5fe463f 100644 --- a/src/app/api/cron/[password]/route.ts +++ b/src/app/api/cron/[password]/route.ts @@ -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 { + 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 const detailCache = new Map>(); + const mangaDetailCache = new Map< + string, + Promise<{ chapters: MangaChapter[]; shelfItem: Partial } | 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 } | 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); + } }; // 分批并行处理用户,避免并发过高 diff --git a/src/app/api/manga/image/route.ts b/src/app/api/manga/image/route.ts index ab134db..822e2d6 100644 --- a/src/app/api/manga/image/route.ts +++ b/src/app/api/manga/image/route.ts @@ -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 => { 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; diff --git a/src/app/manga/detail/page.tsx b/src/app/manga/detail/page.tsx index c52561f..b032356 100644 --- a/src/app/manga/detail/page.tsx +++ b/src/app/manga/detail/page.tsx @@ -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>({}); const [shelf, setShelf] = useState>({}); const [descOrder, setDescOrder] = useState(true); + const clearedOnOpenRef = useRef(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(); + 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 ? : } {descOrder ? '倒序' : '正序'} + {unreadChapterCount > 0 && latestChapter && ( +
+ 已更新 {unreadChapterCount} 话,最新章节:{latestChapter.name} +
+ )}
{chapters.map((chapter) => { const active = currentRecord?.chapterId === chapter.id; + const isNewChapter = newChapterIds.has(chapter.id); return ( -
{chapter.name}
+
+
{chapter.name}
+ {isNewChapter && ( + + NEW + + )} +
{formatChapterMeta(chapter)} {active && currentRecord ? ` · 上次看到第 ${currentRecord.pageIndex + 1} 页` : ''} diff --git a/src/app/manga/read/page.tsx b/src/app/manga/read/page.tsx index 01b2c6b..af9495c 100644 --- a/src/app/manga/read/page.tsx +++ b/src/app/manga/read/page.tsx @@ -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); diff --git a/src/app/manga/shelf/page.tsx b/src/app/manga/shelf/page.tsx index acdc591..6639019 100644 --- a/src/app/manga/shelf/page.tsx +++ b/src/app/manga/shelf/page.tsx @@ -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>( + 'mangaShelfUpdated', + setShelf + ); + getAllMangaShelf() .then(setShelf) .catch(() => undefined) .finally(() => setLoading(false)); + + return unsubscribe; }, []); const shelfList = useMemo( @@ -71,7 +78,12 @@ export default function MangaShelfPage() { 0 + ? `更新至 ${item.latestChapterName || '最新章节'} · 新增 ${item.unreadChapterCount} 话` + : item.lastChapterName || item.author || item.status + } + updateCount={item.unreadChapterCount} />