From 8bf48c18f1d1bd728ac8352d2d24e1658c6b8ed9 Mon Sep 17 00:00:00 2001 From: mmdctjj <984808285@qq.com> Date: Mon, 15 Jun 2026 12:01:15 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E5=BE=AE?= =?UTF-8?q?=E8=BD=AF=E9=9F=B3=E8=89=B2=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mobile/src/screens/TTSScreen.tsx | 848 +++++++++++++++++--------- tts-service/app/providers/edge_tts.py | 96 ++- 2 files changed, 635 insertions(+), 309 deletions(-) diff --git a/apps/mobile/src/screens/TTSScreen.tsx b/apps/mobile/src/screens/TTSScreen.tsx index 5e4e1f04..e00eb966 100644 --- a/apps/mobile/src/screens/TTSScreen.tsx +++ b/apps/mobile/src/screens/TTSScreen.tsx @@ -55,66 +55,22 @@ const providerLabel = (name: string) => { /** * Format voice name for display. - * Edge TTS names are long like "Microsoft Server Speech Text to Speech Voice (zh-CN, XiaoxiaoNeural)" - * We extract just the Chinese name and region tag. + * + * The TTS backend (tts-service) already converts verbose Edge TTS + * FriendlyName strings like + * "Microsoft Server Speech Text to Speech Voice (zh-CN, XiaoxiaoNeural)" + * into short labels like "晓晓·陆" before returning the voice list. + * So in the common case we just return `voice.name` as-is. + * + * This helper only handles the fallback (e.g. cached older responses, + * or non-Edge providers that don't pre-translate): truncate overly + * long names so the picker stays readable. */ -function formatVoiceName(voice: TTSVoice, providerName: string): string { - const name = voice.name || ''; - - // 小米 TTS: return name as-is, no region tag - if (providerName === 'mi') { - return name; - } - - // Edge TTS: extract name from parentheses, e.g. "(zh-CN, XiaoxiaoNeural)" - const match = name.match(/\(([a-z]{2}-[A-Z]{2}),\s*([^)]+)\)/); - if (match) { - const region = match[1].toLowerCase(); // e.g. "zh-cn", "zh-hk", "zh-tw" - const voiceId = match[2]; // e.g. "XiaoxiaoNeural" - - // Map voice IDs to Chinese names - const nameMap: Record = { - 'XiaoxiaoNeural': '晓晓', - 'XiaoyiNeural': '晓伊', - 'YunjianNeural': '云健', - 'YunxiNeural': '云希', - 'YunxiaNeural': '云夏', - 'YunyangNeural': '云扬', - 'XiaochenNeural': '晓辰', - 'XiaohanNeural': '晓涵', - 'XiaomengNeural': '晓梦', - 'XiaomoNeural': '晓墨', - 'XiaoqiuNeural': '晓秋', - 'XiaoruiNeural': '晓睿', - 'XiaoshuangNeural': '晓双', - 'XiaoyanNeural': '晓颜', - 'XiaoyouNeural': '晓悠', - 'XiaozhenNeural': '晓甄', - 'YunfengNeural': '云枫', - 'YunhaoNeural': '云浩', - 'YunyeNeural': '云野', - 'YunzeNeural': '云泽', - 'HiuMaanNeural': '晓曼', - 'WanLungNeural': '云龙', - 'HsiaoChenNeural': '晓臻', - 'HsiaoYuNeural': '晓雨', - 'YunJheNeural': '云哲', - }; - - const chineseName = nameMap[voiceId] || voiceId.replace('Neural', ''); - - // Region tag for Edge TTS only - use shorter tags - let regionTag = ''; - if (region === 'zh-hk' || region === 'zh-hant-hk') regionTag = '港'; - else if (region === 'zh-tw' || region === 'zh-hant-tw') regionTag = '台'; - else if (region === 'zh-cn' || region === 'zh-hans') regionTag = '陆'; - - return regionTag ? `${chineseName}·${regionTag}` : chineseName; - } - - // For other providers or fallback, return name as-is but truncate if too long +function formatVoiceName(voice: TTSVoice, _providerName: string): string { + const name = voice.name || ""; + if (!name) return "默认"; if (name.length > 20) { - return name.slice(0, 18) + '...'; + return name.slice(0, 18) + "..."; } return name; } @@ -171,9 +127,18 @@ export function TTSScreen() { const currentParagraph = ttsStore.currentParagraph; // Local setters for chapter data (since these come from API, not store) - const setParagraphs = useCallback((p: Paragraph[]) => ttsStore.setParagraphs(p), [ttsStore]); - const setChapterTitle = useCallback((t: string) => ttsStore.setChapterTitle(t), [ttsStore]); - const setChapterIndex = useCallback((i: number) => ttsStore.setChapterIndex(i), [ttsStore]); + const setParagraphs = useCallback( + (p: Paragraph[]) => ttsStore.setParagraphs(p), + [ttsStore], + ); + const setChapterTitle = useCallback( + (t: string) => ttsStore.setChapterTitle(t), + [ttsStore], + ); + const setChapterIndex = useCallback( + (i: number) => ttsStore.setChapterIndex(i), + [ttsStore], + ); // ── Local UI state ────────────────────────────────────────────────── const [chapters, setChapters] = useState<{ title: string; index: number }[]>( @@ -201,14 +166,16 @@ export function TTSScreen() { const [sleepRemaining, setSleepRemaining] = useState(0); // View mode: 'controls' = cover + controls, 'content' = paragraph list - const [viewMode, setViewMode] = useState<'controls' | 'content'>('controls'); + const [viewMode, setViewMode] = useState<"controls" | "content">("controls"); // Screen width for responsive layout - const [screenWidth, setScreenWidth] = useState(Dimensions.get('window').width); + const [screenWidth, setScreenWidth] = useState( + Dimensions.get("window").width, + ); const isWideScreen = screenWidth > 600; useEffect(() => { - const subscription = Dimensions.addEventListener('change', ({ window }) => { + const subscription = Dimensions.addEventListener("change", ({ window }) => { setScreenWidth(window.width); }); return () => subscription?.remove(); @@ -219,9 +186,12 @@ export function TTSScreen() { const progress = useProgress(); // Use ttsStore state for UI to avoid flicker during paragraph transitions - const isPlaying = ttsStore.state === 'playing'; - const isPaused = ttsStore.state === 'paused'; - const isLoadingAudio = ttsStore.state === 'loading' || playbackState === State.Connecting || playbackState === State.Buffering; + const isPlaying = ttsStore.state === "playing"; + const isPaused = ttsStore.state === "paused"; + const isLoadingAudio = + ttsStore.state === "loading" || + playbackState === State.Connecting || + playbackState === State.Buffering; const [paragraphProgress, setParagraphProgress] = useState(0); const [resumeOffsetMs, setResumeOffsetMs] = useState(0); @@ -261,7 +231,7 @@ export function TTSScreen() { progressUpdateEventInterval: 1, }); // Only reset if nothing is playing (avoid interrupting playback from mini player) - if (ttsStore.state !== 'playing') { + if (ttsStore.state !== "playing") { await TrackPlayer.reset(); } } catch (e) { @@ -278,24 +248,28 @@ export function TTSScreen() { let cancelled = false; (async () => { // If already playing this book (expanding from mini player), skip reload - if (ttsStore.currentBookId === book.id && ttsStore.paragraphs.length > 0 && ttsStore.state === 'playing') { - console.log('[TTSScreen] Already playing this book, skipping reload'); + if ( + ttsStore.currentBookId === book.id && + ttsStore.paragraphs.length > 0 && + ttsStore.state === "playing" + ) { + console.log("[TTSScreen] Already playing this book, skipping reload"); setLoading(false); return; } - + setLoading(true); setLoadError(null); try { const apiClient = getApiClient(); - console.log('[TTSScreen] Starting load, book.id:', book.id); - + console.log("[TTSScreen] Starting load, book.id:", book.id); + // Save book data to store for mini player ttsStore.setCurrentBookData(book); // Providers const provRes = await apiClient.getTtsProviders(); - console.log('[TTSScreen] Providers response:', JSON.stringify(provRes)); + console.log("[TTSScreen] Providers response:", JSON.stringify(provRes)); if (cancelled) return; const ps = (provRes.success && provRes.data?.providers) || []; setProviders(ps); @@ -303,24 +277,24 @@ export function TTSScreen() { const finalProvider = enabledNames.includes(provider) ? provider : enabledNames[0] || "edge"; - console.log('[TTSScreen] Final provider:', finalProvider); + console.log("[TTSScreen] Final provider:", finalProvider); if (finalProvider !== provider) setProvider(finalProvider); // Voices const vRes = await apiClient.getVoices(finalProvider); - console.log('[TTSScreen] Voices response:', JSON.stringify(vRes)); + console.log("[TTSScreen] Voices response:", JSON.stringify(vRes)); if (cancelled) return; const vs = (vRes.success && vRes.data) || []; setVoices(vs); if (!voiceId && vs[0]) setVoiceId(vs[0].id); // Chapters - console.log('[TTSScreen] Loading chapters for book:', book.id); + console.log("[TTSScreen] Loading chapters for book:", book.id); const chRes = await apiClient.getChapters(book.id); - console.log('[TTSScreen] Chapters response:', JSON.stringify(chRes)); + console.log("[TTSScreen] Chapters response:", JSON.stringify(chRes)); if (cancelled) return; if (!chRes.success || !chRes.data || chRes.data.length === 0) { - console.log('[TTSScreen] No chapters found'); + console.log("[TTSScreen] No chapters found"); setLoadError("本书暂无章节内容,请先解析章节。"); return; } @@ -329,10 +303,15 @@ export function TTSScreen() { ttsStore.setChapters(chRes.data); // Load first chapter (loadChapter will skip empty ones) - console.log('[TTSScreen] Loading chapter 0, voice:', vs[0]?.id || voiceId, 'provider:', finalProvider); + console.log( + "[TTSScreen] Loading chapter 0, voice:", + vs[0]?.id || voiceId, + "provider:", + finalProvider, + ); await loadChapter(0, vs[0]?.id || voiceId, finalProvider); } catch (e) { - console.error('[TTSScreen] Load error:', e); + console.error("[TTSScreen] Load error:", e); setLoadError((e as Error).message || "加载失败"); } finally { if (!cancelled) setLoading(false); @@ -407,65 +386,79 @@ export function TTSScreen() { // ── TrackPlayer event: track ended → auto advance ─────────────────── useEffect(() => { - const sub = TrackPlayer.addEventListener(Event.PlaybackQueueEnded, async () => { - console.log('[TTSScreen] Queue ended'); - const nextIdx = currentParagraph + 1; - if (nextIdx < paragraphs.length) { - // More paragraphs in chapter, continue playing - await playParagraph(nextIdx); - } else { - // Chapter ended, try next chapter - const nextChapter = chapterIndex + 1; - if (nextChapter < chaptersRef.current.length) { - await loadChapter(nextChapter, voiceId, provider); - await playParagraph(0); + const sub = TrackPlayer.addEventListener( + Event.PlaybackQueueEnded, + async () => { + console.log("[TTSScreen] Queue ended"); + const nextIdx = currentParagraph + 1; + if (nextIdx < paragraphs.length) { + // More paragraphs in chapter, continue playing + await playParagraph(nextIdx); } else { - // Truly done - no more chapters - ttsStore.setState("idle"); + // Chapter ended, try next chapter + const nextChapter = chapterIndex + 1; + if (nextChapter < chaptersRef.current.length) { + await loadChapter(nextChapter, voiceId, provider); + await playParagraph(0); + } else { + // Truly done - no more chapters + ttsStore.setState("idle"); + } } - } - }); + }, + ); return () => sub.remove(); - }, [currentParagraph, paragraphs.length, chapterIndex, voiceId, provider, ttsStore]); + }, [ + currentParagraph, + paragraphs.length, + chapterIndex, + voiceId, + provider, + ttsStore, + ]); // ── TrackPlayer event: track changed (user clicked next/prev in system UI) ── useEffect(() => { - const sub = TrackPlayer.addEventListener(Event.PlaybackTrackChanged, async (event) => { - // event.nextTrack is the new track index - // When user clicks "next" in system UI, we need to update currentParagraph - if (event.nextTrack !== undefined && event.nextTrack !== null) { - // The track index corresponds to the paragraph index within the queue - // Our queue has: [currentParagraph chunks..., nextParagraph chunks...] - // So we need to map track index back to paragraph index - const trackIndex = event.nextTrack; - // Count how many tracks belong to current paragraph - const currentParaTrackCount = await TrackPlayer.getQueue().then(q => { - let count = 0; - for (let i = 0; i < q.length; i++) { - if (q[i].id.startsWith(`${paragraphs[currentParagraph]?.id}`)) { - count++; - } else { - break; + const sub = TrackPlayer.addEventListener( + Event.PlaybackTrackChanged, + async (event) => { + // event.nextTrack is the new track index + // When user clicks "next" in system UI, we need to update currentParagraph + if (event.nextTrack !== undefined && event.nextTrack !== null) { + // The track index corresponds to the paragraph index within the queue + // Our queue has: [currentParagraph chunks..., nextParagraph chunks...] + // So we need to map track index back to paragraph index + const trackIndex = event.nextTrack; + // Count how many tracks belong to current paragraph + const currentParaTrackCount = await TrackPlayer.getQueue() + .then((q) => { + let count = 0; + for (let i = 0; i < q.length; i++) { + if (q[i].id.startsWith(`${paragraphs[currentParagraph]?.id}`)) { + count++; + } else { + break; + } + } + return count; + }) + .catch(() => 1); + + if (trackIndex >= currentParaTrackCount) { + // User moved to next paragraph + const nextParagraphIdx = currentParagraph + 1; + if (nextParagraphIdx < paragraphs.length) { + ttsStore.setCurrentParagraph(nextParagraphIdx); + // Pre-load next next paragraph for continuous playback + prefetchParagraph(nextParagraphIdx + 1); } } - return count; - }).catch(() => 1); - - if (trackIndex >= currentParaTrackCount) { - // User moved to next paragraph - const nextParagraphIdx = currentParagraph + 1; - if (nextParagraphIdx < paragraphs.length) { - ttsStore.setCurrentParagraph(nextParagraphIdx); - // Pre-load next next paragraph for continuous playback - prefetchParagraph(nextParagraphIdx + 1); - } } - } - }); + }, + ); return () => sub.remove(); }, [currentParagraph, paragraphs, ttsStore]); - // Auto-scroll to current paragraph when it changes useEffect(() => { if (flatListRef.current && currentParagraph >= 0 && paragraphs.length > 0) { @@ -478,7 +471,7 @@ export function TTSScreen() { }); } catch (e) { // Fallback to scrollToOffset if scrollToIndex fails - console.warn('scrollToIndex failed, falling back'); + console.warn("scrollToIndex failed, falling back"); } }, 100); return () => clearTimeout(timeout); @@ -487,7 +480,12 @@ export function TTSScreen() { // Scroll to current paragraph when entering content view (initial mount) useEffect(() => { - if (viewMode === 'content' && flatListRef.current && currentParagraph >= 0 && paragraphs.length > 0) { + if ( + viewMode === "content" && + flatListRef.current && + currentParagraph >= 0 && + paragraphs.length > 0 + ) { const timeout = setTimeout(() => { try { flatListRef.current?.scrollToIndex({ @@ -496,12 +494,12 @@ export function TTSScreen() { viewPosition: 0.5, }); } catch (e) { - console.warn('Initial scrollToIndex failed:', e); + console.warn("Initial scrollToIndex failed:", e); } }, 300); return () => clearTimeout(timeout); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [viewMode]); const loadChapter = async (ci: number, vid: string, prov: string) => { @@ -588,17 +586,29 @@ export function TTSScreen() { // ── Persist latest position when user leaves the screen ───────────── useEffect(() => { const unsubscribe = navigation.addListener("beforeRemove", async () => { - await persistProgress(currentParagraph, Math.round(progress.position * 1000)); + await persistProgress( + currentParagraph, + Math.round(progress.position * 1000), + ); ttsStore.setMiniPlayerVisible(true); }); return unsubscribe; - }, [currentParagraph, navigation, persistProgress, progress.position, ttsStore]); + }, [ + currentParagraph, + navigation, + persistProgress, + progress.position, + ttsStore, + ]); // ── Persist progress when app goes to background ──────────────────── useEffect(() => { const subscription = AppState.addEventListener("change", (nextAppState) => { if (nextAppState === "background" || nextAppState === "inactive") { - void persistProgress(currentParagraph, Math.round(progress.position * 1000)); + void persistProgress( + currentParagraph, + Math.round(progress.position * 1000), + ); } }); return () => subscription.remove(); @@ -713,7 +723,10 @@ export function TTSScreen() { })); } } catch (e) { - console.warn('[TTSScreen] Pre-synthesize next paragraph failed:', e); + console.warn( + "[TTSScreen] Pre-synthesize next paragraph failed:", + e, + ); } } @@ -753,7 +766,10 @@ export function TTSScreen() { if (isPlaying) { await TrackPlayer.pause(); ttsStore.setState("paused"); - await persistProgress(currentParagraph, Math.round(progress.position * 1000)); + await persistProgress( + currentParagraph, + Math.round(progress.position * 1000), + ); return; } if (!paragraphs.length) { @@ -761,7 +777,16 @@ export function TTSScreen() { return; } await playParagraph(currentParagraph); - }, [isPaused, isPlaying, paragraphs.length, playParagraph, currentParagraph, ttsStore, persistProgress, progress.position]); + }, [ + isPaused, + isPlaying, + paragraphs.length, + playParagraph, + currentParagraph, + ttsStore, + persistProgress, + progress.position, + ]); const handleStop = useCallback(async () => { await TrackPlayer.pause(); @@ -781,7 +806,14 @@ export function TTSScreen() { void persistProgress(idx); } }, - [paragraphs.length, isPlaying, isPaused, playParagraph, persistProgress, ttsStore], + [ + paragraphs.length, + isPlaying, + isPaused, + playParagraph, + persistProgress, + ttsStore, + ], ); const handleSkipBack = useCallback(() => { @@ -835,7 +867,7 @@ export function TTSScreen() { }, []); const handleToggleViewMode = useCallback(() => { - setViewMode((prev) => (prev === 'controls' ? 'content' : 'controls')); + setViewMode((prev) => (prev === "controls" ? "content" : "controls")); }, []); const handleMinimize = useCallback(() => { @@ -855,19 +887,30 @@ export function TTSScreen() { {!isWideScreen && ( - - )} - setShowSettings(true)}> - + setShowSettings(true)} + > + ); @@ -878,7 +921,12 @@ export function TTSScreen() { onPress={handleToggleViewMode} activeOpacity={0.9} > - + {book.coverUrl ? ( ) : ( - {(book.title || '?').charAt(0)} + + {(book.title || "?").charAt(0)} + )} {sleepMinutes > 0 && ( - + {Math.floor(sleepRemaining / 60)}m @@ -902,11 +954,11 @@ export function TTSScreen() { const renderChapterInfo = () => ( - {book.title || '未知书籍'} + {book.title || "未知书籍"} ); - const renderDescription = () => ( + const renderDescription = () => book.description && ( 简介 @@ -914,8 +966,7 @@ export function TTSScreen() { {book.description} - ) - ); + ); const renderProgress = () => ( @@ -925,11 +976,19 @@ export function TTSScreen() { {Math.round(overallProgress * 100)}% - + @@ -937,7 +996,7 @@ export function TTSScreen() { ); const handleReadBook = useCallback(() => { - navigation.navigate('Reader', { book }); + navigation.navigate("Reader", { book }); }, [navigation, book]); const renderControls = () => ( @@ -953,11 +1012,22 @@ export function TTSScreen() { style={[styles.iconButton, { backgroundColor: theme.colors.surface }]} onPress={handleSkipBack} > - + @@ -965,7 +1035,7 @@ export function TTSScreen() { ) : ( @@ -976,7 +1046,11 @@ export function TTSScreen() { style={[styles.iconButton, { backgroundColor: theme.colors.surface }]} onPress={handleSkipForward} > - + ( - + {renderProgress()} {renderControls()} @@ -1009,7 +1085,7 @@ export function TTSScreen() { onPress={() => handleJumpToParagraph(index)} style={[ styles.paragraphItem, - isCurrent && { backgroundColor: theme.colors.primary + '25' }, + isCurrent && { backgroundColor: theme.colors.primary + "25" }, ]} > { // Estimate item height based on text length (approx 40 chars per line, 24px per line + padding) - const text = data?.[index]?.text || ''; + const text = data?.[index]?.text || ""; const lines = Math.max(1, Math.ceil(text.length / 40)); const height = lines * 24 + 16; // 16px for padding (8 top + 8 bottom) return { length: height, offset: height * index, index }; @@ -1042,7 +1118,7 @@ export function TTSScreen() { minIndexForVisible: 0, }} onScrollToIndexFailed={(info) => { - console.warn('Scroll to index failed:', info); + console.warn("Scroll to index failed:", info); // Fallback: scroll to approximate offset flatListRef.current?.scrollToOffset({ offset: info.averageItemLength * info.index, @@ -1063,10 +1139,18 @@ export function TTSScreen() { > setShowSpeedPicker(false)} /> - + @@ -1085,10 +1169,17 @@ export function TTSScreen() { }} style={[ styles.sleepOption, - ttsStore.playbackRate === r && { backgroundColor: theme.colors.primary }, + ttsStore.playbackRate === r && { + backgroundColor: theme.colors.primary, + }, ]} > - + {r}x @@ -1108,10 +1199,18 @@ export function TTSScreen() { > setShowTimerPicker(false)} /> - + @@ -1130,11 +1229,18 @@ export function TTSScreen() { }} style={[ styles.sleepOption, - sleepMinutes === minutes && { backgroundColor: theme.colors.primary }, + sleepMinutes === minutes && { + backgroundColor: theme.colors.primary, + }, ]} > - - {minutes === 0 ? '关闭' : `${minutes} 分钟`} + + {minutes === 0 ? "关闭" : `${minutes} 分钟`} ))} @@ -1153,10 +1259,18 @@ export function TTSScreen() { > setShowSettings(false)} /> - + @@ -1172,12 +1286,32 @@ export function TTSScreen() { setShowSpeedPicker(true); }} > - - 播放倍速 - + + + 播放倍速 + + {ttsStore.playbackRate.toFixed(1)}x - + - - 定时关闭 - - {sleepMinutes > 0 ? `${sleepMinutes}分钟` : '关闭'} + + + 定时关闭 + + + {sleepMinutes > 0 ? `${sleepMinutes}分钟` : "关闭"} - + - - TTS 设置 - - {providerLabel(provider)} · {(() => { + + + TTS 设置 + + + {providerLabel(provider)} ·{" "} + {(() => { const v = voices.find((v) => v.id === voiceId); - return v ? formatVoiceName(v, provider) : '默认'; + return v ? formatVoiceName(v, provider) : "默认"; })()} - + - + @@ -1229,17 +1410,30 @@ export function TTSScreen() { > setShowTTSConfig(false)} /> - + TTS 设置 - + TTS 服务商 @@ -1253,26 +1447,38 @@ export function TTSScreen() { style={[ styles.chip, { - backgroundColor: active ? theme.colors.primary : theme.colors.background, + backgroundColor: active + ? theme.colors.primary + : theme.colors.background, opacity: p.enabled ? 1 : 0.4, }, ]} > - + {providerLabel(p.name)} ); })} - + 音色 {voices .filter((v) => { - const lang = (v.language || v.lang || '').toLowerCase(); - return lang.startsWith('zh'); + const lang = (v.language || v.lang || "").toLowerCase(); + return lang.startsWith("zh"); }) .map((v) => { const active = voiceId === v.id; @@ -1285,19 +1491,35 @@ export function TTSScreen() { }} style={[ styles.voiceListItem, - active && { backgroundColor: theme.colors.primary + '20', borderColor: theme.colors.primary }, + active && { + backgroundColor: theme.colors.primary + "20", + borderColor: theme.colors.primary, + }, ]} > - + {formatVoiceName(v, provider)} - {v.language || v.lang || 'zh-CN'} + {v.language || v.lang || "zh-CN"} {active && ( - + )} ); @@ -1318,10 +1540,18 @@ export function TTSScreen() { > setShowChapterPicker(false)} /> - + @@ -1339,16 +1569,24 @@ export function TTSScreen() { onPress={() => handleChapterChange(c.index)} style={[ styles.chapterListItem, - active && { backgroundColor: theme.colors.primary + '20' }, + active && { + backgroundColor: theme.colors.primary + "20", + }, ]} > - {c.index + 1}. + + {c.index + 1}.{" "} + {c.title} @@ -1429,7 +1667,7 @@ export function TTSScreen() { } // ── Mobile Layout: Controls View ─────────────────────────────────── - if (viewMode === 'controls') { + if (viewMode === "controls") { return ( ) { }, center: { flex: 1, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", gap: spacing.md, }, muted: { @@ -1479,7 +1717,7 @@ function createStyles(theme: ReturnType) { errorText: { color: theme.colors.error, fontSize: fontSizes.md, - textAlign: 'center', + textAlign: "center", }, button: { paddingHorizontal: spacing.lg, @@ -1487,14 +1725,14 @@ function createStyles(theme: ReturnType) { borderRadius: borderRadius.md, }, buttonText: { - color: '#fff', + color: "#fff", fontSize: fontSizes.md, - fontWeight: '600', + fontWeight: "600", }, // Header header: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", paddingHorizontal: spacing.md, paddingTop: spacing.xl, paddingBottom: spacing.sm, @@ -1502,11 +1740,11 @@ function createStyles(theme: ReturnType) { headerButton: { padding: spacing.sm, width: 48, - alignItems: 'center', + alignItems: "center", }, headerCenter: { flex: 1, - alignItems: 'center', + alignItems: "center", }, headerTitle: { fontSize: fontSizes.sm, @@ -1514,31 +1752,31 @@ function createStyles(theme: ReturnType) { }, headerSubtitle: { fontSize: fontSizes.md, - fontWeight: '600', + fontWeight: "600", color: theme.colors.text, }, // Wide screen layout wideContainer: { flex: 1, - flexDirection: 'row', + flexDirection: "row", }, wideLeftPanel: { width: 360, borderRightWidth: 1, - borderRightColor: theme.colors.border + '30', + borderRightColor: theme.colors.border + "30", }, wideLeftContent: { padding: spacing.lg, paddingTop: spacing.xl, paddingBottom: spacing.xxl, - alignItems: 'center', + alignItems: "center", }, wideRightPanel: { flex: 1, }, wideRightHeader: { fontSize: fontSizes.lg, - fontWeight: '600', + fontWeight: "600", color: theme.colors.text, paddingHorizontal: spacing.lg, paddingTop: spacing.lg, @@ -1549,33 +1787,33 @@ function createStyles(theme: ReturnType) { padding: spacing.lg, paddingTop: spacing.xl, paddingBottom: spacing.xxl, - alignItems: 'center', + alignItems: "center", }, coverSection: { - alignItems: 'center', + alignItems: "center", marginBottom: spacing.lg, - position: 'relative', + position: "relative", }, bookCoverLarge: { width: 160, height: 224, borderRadius: borderRadius.xl, - justifyContent: 'center', - alignItems: 'center', - shadowColor: '#000', + justifyContent: "center", + alignItems: "center", + shadowColor: "#000", shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 8, elevation: 8, - overflow: 'hidden', + overflow: "hidden", }, bookCoverImage: { - width: '100%', - height: '100%', + width: "100%", + height: "100%", }, coverInitialLarge: { fontSize: 52, - fontWeight: 'bold', + fontWeight: "bold", color: theme.colors.primary, }, tapHint: { @@ -1584,11 +1822,11 @@ function createStyles(theme: ReturnType) { marginTop: spacing.sm, }, sleepBadge: { - position: 'absolute', + position: "absolute", top: spacing.sm, - right: '20%', - flexDirection: 'row', - alignItems: 'center', + right: "20%", + flexDirection: "row", + alignItems: "center", paddingHorizontal: spacing.sm, paddingVertical: spacing.xs, borderRadius: borderRadius.full, @@ -1596,65 +1834,65 @@ function createStyles(theme: ReturnType) { }, sleepBadgeText: { fontSize: fontSizes.xs, - color: '#fff', - fontWeight: '600', + color: "#fff", + fontWeight: "600", }, chapterTitleLarge: { fontSize: fontSizes.xl, - fontWeight: '600', + fontWeight: "600", color: theme.colors.text, - textAlign: 'center', + textAlign: "center", marginBottom: spacing.xs, }, bookAuthorLarge: { fontSize: fontSizes.md, color: theme.colors.textSecondary, - textAlign: 'center', + textAlign: "center", marginBottom: spacing.lg, }, progressSection: { - width: '100%', + width: "100%", marginBottom: spacing.lg, gap: spacing.xs, }, progressLabelsRow: { - flexDirection: 'row', - justifyContent: 'space-between', + flexDirection: "row", + justifyContent: "space-between", paddingHorizontal: spacing.xs, }, progressBar: { height: 6, borderRadius: 3, - overflow: 'hidden', + overflow: "hidden", }, progressFill: { - height: '100%', + height: "100%", borderRadius: 3, }, // Controls row controlsRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', + flexDirection: "row", + alignItems: "center", + justifyContent: "center", gap: spacing.sm, }, iconButton: { width: 44, height: 44, borderRadius: 22, - justifyContent: 'center', - alignItems: 'center', - position: 'relative', + justifyContent: "center", + alignItems: "center", + position: "relative", }, playButtonLarge: { width: 72, height: 72, borderRadius: 36, - justifyContent: 'center', - alignItems: 'center', + justifyContent: "center", + alignItems: "center", }, badge: { - position: 'absolute', + position: "absolute", top: -4, right: -4, borderRadius: borderRadius.sm, @@ -1663,12 +1901,12 @@ function createStyles(theme: ReturnType) { }, badgeText: { fontSize: 10, - color: '#fff', - fontWeight: '600', + color: "#fff", + fontWeight: "600", }, // Picker dropdown pickerDropdown: { - width: '100%', + width: "100%", paddingHorizontal: spacing.md, paddingVertical: spacing.sm, borderRadius: borderRadius.lg, @@ -1676,7 +1914,7 @@ function createStyles(theme: ReturnType) { }, // Settings settingsPanel: { - width: '100%', + width: "100%", padding: spacing.md, borderRadius: borderRadius.lg, gap: spacing.xs, @@ -1684,7 +1922,7 @@ function createStyles(theme: ReturnType) { }, settingsTitle: { fontSize: fontSizes.md, - fontWeight: '600', + fontWeight: "600", color: theme.colors.text, marginBottom: spacing.xs, }, @@ -1694,8 +1932,8 @@ function createStyles(theme: ReturnType) { marginTop: spacing.sm, }, chipRow: { - flexDirection: 'row', - flexWrap: 'wrap', + flexDirection: "row", + flexWrap: "wrap", gap: spacing.xs, paddingVertical: spacing.xs, }, @@ -1709,7 +1947,7 @@ function createStyles(theme: ReturnType) { }, // Description descriptionPanel: { - width: '100%', + width: "100%", }, descriptionText: { fontSize: fontSizes.sm, @@ -1722,7 +1960,7 @@ function createStyles(theme: ReturnType) { paddingTop: spacing.sm, paddingBottom: 20, borderTopWidth: 1, - borderTopColor: theme.colors.border + '30', + borderTopColor: theme.colors.border + "30", }, contentViewContainer: { flex: 1, @@ -1743,7 +1981,7 @@ function createStyles(theme: ReturnType) { }, // Modal modalOverlay: { - position: 'absolute', + position: "absolute", top: 0, left: 0, right: 0, @@ -1752,34 +1990,34 @@ function createStyles(theme: ReturnType) { }, modalBackdrop: { flex: 1, - backgroundColor: 'rgba(0,0,0,0.4)', + backgroundColor: "rgba(0,0,0,0.4)", }, modalContent: { - position: 'absolute', + position: "absolute", bottom: 0, left: 0, right: 0, - maxHeight: '70%', + maxHeight: "70%", borderTopLeftRadius: borderRadius.xl, borderTopRightRadius: borderRadius.xl, padding: spacing.lg, }, modalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", marginBottom: spacing.md, }, modalTitle: { fontSize: fontSizes.lg, - fontWeight: '600', + fontWeight: "600", color: theme.colors.text, }, chapterListItem: { paddingVertical: spacing.md, paddingHorizontal: spacing.sm, borderBottomWidth: 1, - borderBottomColor: theme.colors.border + '30', + borderBottomColor: theme.colors.border + "30", }, chapterListText: { fontSize: fontSizes.md, @@ -1795,7 +2033,7 @@ function createStyles(theme: ReturnType) { paddingVertical: spacing.md, borderRadius: borderRadius.md, backgroundColor: theme.colors.background, - alignItems: 'center', + alignItems: "center", }, sleepOptionText: { fontSize: fontSizes.md, @@ -1807,25 +2045,25 @@ function createStyles(theme: ReturnType) { marginTop: spacing.xs, }, voiceListItem: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", paddingVertical: spacing.sm, paddingHorizontal: spacing.md, borderRadius: borderRadius.md, borderWidth: 1, - borderColor: theme.colors.border + '40', + borderColor: theme.colors.border + "40", backgroundColor: theme.colors.background, }, voiceListLeft: { flex: 1, - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: spacing.sm, }, voiceListName: { fontSize: fontSizes.md, - fontWeight: '500', + fontWeight: "500", }, voiceListLang: { fontSize: fontSizes.sm, @@ -1833,12 +2071,12 @@ function createStyles(theme: ReturnType) { }, // Bottom Sheet (More Options) sheetWrapper: { - position: 'absolute', + position: "absolute", bottom: 0, left: 0, right: 0, - alignItems: 'center', - maxHeight: '60%', + alignItems: "center", + maxHeight: "60%", borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingTop: 8, @@ -1846,32 +2084,32 @@ function createStyles(theme: ReturnType) { paddingBottom: spacing.xl, }, sheetInner: { - width: '100%', + width: "100%", maxWidth: 600, }, sheetHandle: { width: 40, height: 4, - backgroundColor: 'rgba(150,150,150,0.3)', + backgroundColor: "rgba(150,150,150,0.3)", borderRadius: 2, - alignSelf: 'center', + alignSelf: "center", marginBottom: spacing.md, marginTop: spacing.xs, }, sheetTitle: { fontSize: fontSizes.xl, - fontWeight: '600', - textAlign: 'center', + fontWeight: "600", + textAlign: "center", marginBottom: spacing.lg, }, sheetQuickControls: { - flexDirection: 'row', - justifyContent: 'space-around', + flexDirection: "row", + justifyContent: "space-around", paddingVertical: spacing.md, marginBottom: spacing.sm, }, sheetQuickItem: { - alignItems: 'center', + alignItems: "center", gap: spacing.xs, flex: 1, }, @@ -1879,17 +2117,17 @@ function createStyles(theme: ReturnType) { width: 52, height: 52, borderRadius: 26, - justifyContent: 'center', - alignItems: 'center', + justifyContent: "center", + alignItems: "center", marginBottom: spacing.xs, }, sheetQuickLabel: { fontSize: fontSizes.sm, - fontWeight: '500', + fontWeight: "500", }, sheetQuickValue: { fontSize: fontSizes.xs, - fontWeight: '600', + fontWeight: "600", }, sheetDivider: { height: 1, @@ -1897,22 +2135,22 @@ function createStyles(theme: ReturnType) { }, sheetSectionTitle: { fontSize: fontSizes.sm, - fontWeight: '600', + fontWeight: "600", marginTop: spacing.md, marginBottom: spacing.xs, - textTransform: 'uppercase', + textTransform: "uppercase", letterSpacing: 0.5, }, sheetOption: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", paddingVertical: spacing.md, gap: spacing.md, }, sheetOptionText: { flex: 1, fontSize: fontSizes.md, - fontWeight: '500', + fontWeight: "500", }, sheetOptionValue: { fontSize: fontSizes.sm, diff --git a/tts-service/app/providers/edge_tts.py b/tts-service/app/providers/edge_tts.py index e6fafe1f..863daa89 100644 --- a/tts-service/app/providers/edge_tts.py +++ b/tts-service/app/providers/edge_tts.py @@ -20,6 +20,82 @@ DEFAULT_VOICE_LANGUAGES = {"en", "zh", "ja", "ko", "es", "fr", "de", "ru", "it"} +# ─── Friendly display names for Chinese Edge voices ───────────────────── +# Map from ShortName suffix (e.g. "XiaoxiaoNeural" from "zh-CN-XiaoxiaoNeural") +# to a short Chinese display name. Used to replace the verbose +# "Microsoft Server Speech Text to Speech Voice (zh-CN, XiaoxiaoNeural)" +# default friendly name with a cleaner "晓晓·陆" style label. +# The `id` returned to the client remains the ShortName (unchanged), so +# synthesize calls still work without any client-side remapping. +_ZH_VOICE_NAME_MAP: dict[str, str] = { + "XiaoxiaoNeural": "晓晓", + "XiaoyiNeural": "晓伊", + "YunjianNeural": "云健", + "YunxiNeural": "云希", + "YunxiaNeural": "云夏", + "YunyangNeural": "云扬", + "XiaochenNeural": "晓辰", + "XiaohanNeural": "晓涵", + "XiaomengNeural": "晓梦", + "XiaomoNeural": "晓墨", + "XiaoqiuNeural": "晓秋", + "XiaoruiNeural": "晓睿", + "XiaoshuangNeural": "晓双", + "XiaoyanNeural": "晓颜", + "XiaoyouNeural": "晓悠", + "XiaozhenNeural": "晓甄", + "YunfengNeural": "云枫", + "YunhaoNeural": "云浩", + "YunyeNeural": "云野", + "YunzeNeural": "云泽", + "HiuMaanNeural": "晓曼", + "WanLungNeural": "云龙", + "HsiaoChenNeural": "晓臻", + "HsiaoYuNeural": "晓雨", + "YunJheNeural": "云哲", + # Voices added later by Microsoft — only present in newer edge_tts + # builds. Region tag still comes from the voice's Locale. + "HiuGaaiNeural": "晓佳", # zh-HK Cantonese + "XiaobeiNeural": "晓北", # zh-CN-liaoning Northeastern Mandarin + "XiaoniNeural": "晓妮", # zh-CN-shaanxi Zhongyuan Mandarin (Shaanxi) +} + + +def _region_tag(locale: str) -> str: + """Map a BCP-47 locale to a short region tag for Chinese locales. + + Returns "陆" for Simplified Chinese (mainland), "港" for Hong Kong, + "台" for Taiwan. Empty string for any other locale (so the + display name stays clean for non-zh voices). + """ + loc = (locale or "").lower() + if not loc.startswith("zh"): + return "" + if loc.startswith("zh-hk") or "hant-hk" in loc: + return "港" + if loc.startswith("zh-tw") or "hant-tw" in loc: + return "台" + # All other zh-* locales (zh-CN, zh-Hans, zh-Hant-CN, etc.) are + # treated as mainland Mandarin. + return "陆" + + +def _friendly_zh_voice_name(short_name: str, locale: str) -> Optional[str]: + """Build a friendly "晓晓·陆" style name for a Chinese Edge voice. + + Returns None if the voice isn't in our translation table — callers + should fall back to the verbose default FriendlyName in that case. + """ + # ShortName is "zh-CN-XiaoxiaoNeural" or similar. Extract the part + # after the last "-" (e.g. "XiaoxiaoNeural"). + voice_id = short_name.rsplit("-", 1)[-1] if short_name else "" + chinese_name = _ZH_VOICE_NAME_MAP.get(voice_id) + if not chinese_name: + return None + tag = _region_tag(locale) + return f"{chinese_name}·{tag}" if tag else chinese_name + + # rate / pitch / volume must be strings in edge-tts. # Convert a multiplier (0.5–2.0) to edge-tts percent string e.g. "+10%" / "-25%" def _to_rate_str(rate: float) -> str: @@ -69,14 +145,26 @@ async def list_voices(self, language: Optional[str] = None) -> list[VoiceInfo]: short = v.get("ShortName", "") locale = v.get("Locale", "") gender = (v.get("Gender") or "Neutral").lower() - # Filter to common languages to keep the list manageable - lang_prefix = locale.split("-")[0].lower() if locale else "" - if lang_prefix not in DEFAULT_VOICE_LANGUAGES: + # Only show Chinese-region voices (mainland / HK / + # TW). Other locales (en, ja, ko, ...) are hidden + # to keep the picker focused on BookDock's primary + # language. + if not locale.lower().startswith("zh"): continue + # Replace the verbose default FriendlyName + # ("Microsoft Server Speech Text to Speech Voice + # (zh-CN, XiaoxiaoNeural)") with a short + # "晓晓·陆" style name for Chinese voices we + # recognise. The voice `id` (ShortName) is left + # unchanged so clients can still pass it to + # /synthesize directly. + friendly = _friendly_zh_voice_name(short, locale) + if not friendly: + friendly = v.get("FriendlyName", short) parsed.append( VoiceInfo( id=short, - name=v.get("FriendlyName", short), + name=friendly, language=locale, gender=gender, description=f"Microsoft Edge TTS — {locale}", From 35e079ce9e63287e4eaf3242745ee54ce5367546 Mon Sep 17 00:00:00 2001 From: mmdctjj <984808285@qq.com> Date: Mon, 15 Jun 2026 12:52:55 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/pages/Reader-TTS.tsx | 116 +++++++++++++++++++------- apps/mobile/src/screens/TTSScreen.tsx | 52 ++++++++++-- 2 files changed, 132 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/pages/Reader-TTS.tsx b/apps/desktop/src/pages/Reader-TTS.tsx index 9c8084ad..f28081c4 100644 --- a/apps/desktop/src/pages/Reader-TTS.tsx +++ b/apps/desktop/src/pages/Reader-TTS.tsx @@ -48,11 +48,19 @@ import React, { import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { getCoverImageUrl } from "../utils/network"; -function BookCover({ book, className = "" }: { book: Book; className?: string }) { +function BookCover({ + book, + className = "", +}: { + book: Book; + className?: string; +}) { const [coverError, setCoverError] = useState(false); const coverSrc = getCoverImageUrl(book.coverUrl); return ( -
+
{coverSrc && !coverError ? ( = 0) { @@ -278,7 +286,7 @@ export default function ReaderTTS() { } else { try { const lastRes = await apiClient.getBookLastRead(id); - console.log('[Reader-TTS] getBookLastRead res:', lastRes); + console.log("[Reader-TTS] getBookLastRead res:", lastRes); if (lastRes.success && lastRes.data) { ci = lastRes.data.chapterIndex; } else { @@ -297,13 +305,23 @@ export default function ReaderTTS() { } } } catch (e) { - console.error('[Reader-TTS] getBookLastRead error:', e); + console.error("[Reader-TTS] getBookLastRead error:", e); } } - console.log('[Reader-TTS] resolved ci:', ci, 'chapters:', chRes.data.length); + console.log( + "[Reader-TTS] resolved ci:", + ci, + "chapters:", + chRes.data.length, + ); ci = Math.max(0, Math.min(ci, chRes.data.length - 1)); - console.log('[Reader-TTS] clamped ci:', ci); - await loadChapter(apiClient, id, ci); + console.log("[Reader-TTS] clamped ci:", ci); + // skipEmpty: true on initial open so EPUBs that start with a + // cover/copyright page silently advance to the first chapter + // with readable text. User-driven navigation later + // (chapter picker, deep-link, queue-end auto-advance) keeps + // its original ci. + await loadChapter(apiClient, id, ci, { skipEmpty: true }); } catch (err) { setError((err as Error).message); } finally { @@ -318,11 +336,43 @@ export default function ReaderTTS() { apiClient: ReturnType, bookId: string, ci: number, + options: { skipEmpty?: boolean } = {}, ) => { setChapterIndex(ci); chapterIndexRef.current = ci; manager.setConfig({ chapterIndex: ci }); - const r = await apiClient.getChapterParagraphs(bookId, ci); + let r = await apiClient.getChapterParagraphs(bookId, ci); + let effectiveCi = ci; + // Some EPUBs open with cover/copyright/TOC pages that contain no + // readable text. When the user hasn't explicitly asked for that + // chapter (deep-link or manual selection), silently advance to the + // next chapter that actually has paragraphs so the TTS screen + // doesn't fall into its empty-state UI. User-initiated navigation + // (URL ?ci=N, chapter picker, queue end) is still honoured verbatim. + if ( + options.skipEmpty && + (!r.success || !r.data || r.data.paragraphs.length === 0) && + chaptersRef.current.length > 1 + ) { + for (let i = ci + 1; i < chaptersRef.current.length; i++) { + const tryRes = await apiClient.getChapterParagraphs(bookId, i); + if ( + tryRes.success && + tryRes.data && + tryRes.data.paragraphs.length > 0 + ) { + console.log( + `[Reader-TTS] Chapter ${ci} has no readable text; auto-advancing to ${i}`, + ); + effectiveCi = i; + setChapterIndex(i); + chapterIndexRef.current = i; + manager.setConfig({ chapterIndex: i }); + r = tryRes; + break; + } + } + } if (!r.success || !r.data) { showError(r.error || "加载章节失败"); return; @@ -341,7 +391,7 @@ export default function ReaderTTS() { // Resume from saved cloud progress (cross-device sync) try { - const p = await apiClient.getTtsProgress(bookId, ci); + const p = await apiClient.getTtsProgress(bookId, effectiveCi); if (p.success && p.data && !Array.isArray(p.data)) { const rec = p.data as TtsProgressRecord; // Apply the saved voice/provider into the manager IMMEDIATELY @@ -427,7 +477,9 @@ export default function ReaderTTS() { const apiClient = getApiClient(); await loadChapter(apiClient, bookIdRef.current!, nextChapterIndex); // Update URL to reflect new chapter - navigate(`/book/${bookIdRef.current}/tts?ci=${nextChapterIndex}`, { replace: true }); + navigate(`/book/${bookIdRef.current}/tts?ci=${nextChapterIndex}`, { + replace: true, + }); // Wait for state to settle then start playing with fresh paragraphs setTimeout(() => { const cfg = manager.getConfig(); @@ -437,25 +489,29 @@ export default function ReaderTTS() { rate: cfg.rate, volume: cfg.volume, }; - manager.play( - paragraphsRef.current, - 0, - { - onStart: () => setState("playing"), - onPause: () => setState("paused"), - onResume: () => setState("playing"), - onEnd: () => setState("idle"), - onError: (e) => { - showError(e.message || "朗读失败"); - setState("error"); + manager + .play( + paragraphsRef.current, + 0, + { + onStart: () => setState("playing"), + onPause: () => setState("paused"), + onResume: () => setState("playing"), + onEnd: () => setState("idle"), + onError: (e) => { + showError(e.message || "朗读失败"); + setState("error"); + }, + onProgress: (p) => setProgress(p), + onParagraphChange: (idx) => + setProgress((prev) => ({ ...prev, paragraphIndex: idx })), }, - onProgress: (p) => setProgress(p), - onParagraphChange: (idx) => - setProgress((prev) => ({ ...prev, paragraphIndex: idx })), - }, - freshOverrides, - 0, - ).catch((e) => console.error("Auto-play next chapter failed", e)); + freshOverrides, + 0, + ) + .catch((e) => + console.error("Auto-play next chapter failed", e), + ); }, 800); } }, diff --git a/apps/mobile/src/screens/TTSScreen.tsx b/apps/mobile/src/screens/TTSScreen.tsx index e00eb966..62dec9fe 100644 --- a/apps/mobile/src/screens/TTSScreen.tsx +++ b/apps/mobile/src/screens/TTSScreen.tsx @@ -302,14 +302,19 @@ export function TTSScreen() { chaptersRef.current = chRes.data; ttsStore.setChapters(chRes.data); - // Load first chapter (loadChapter will skip empty ones) + // Load first chapter. skipEmpty: true so EPUBs that open with + // a cover/copyright page silently advance to the first chapter + // with readable text instead of dropping the user into the + // empty-state error page. console.log( "[TTSScreen] Loading chapter 0, voice:", vs[0]?.id || voiceId, "provider:", finalProvider, ); - await loadChapter(0, vs[0]?.id || voiceId, finalProvider); + await loadChapter(0, vs[0]?.id || voiceId, finalProvider, { + skipEmpty: true, + }); } catch (e) { console.error("[TTSScreen] Load error:", e); setLoadError((e as Error).message || "加载失败"); @@ -502,11 +507,46 @@ export function TTSScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [viewMode]); - const loadChapter = async (ci: number, vid: string, prov: string) => { + const loadChapter = async ( + ci: number, + vid: string, + prov: string, + options: { skipEmpty?: boolean } = {}, + ) => { setChapterIndex(ci); try { const apiClient = getApiClient(); - const pRes = await apiClient.getChapterParagraphs(book.id, ci); + // Some EPUBs have leading chapters (cover, copyright, dedication, + // table of contents) that contain only images or whitespace and + // would otherwise leave the TTS screen with an empty state. When + // the caller has not explicitly chosen this chapter, silently + // advance to the next chapter that has at least one paragraph. + // User navigation (chapter picker, deep-link, queue-end + // auto-advance) is still honoured verbatim. + let pRes = await apiClient.getChapterParagraphs(book.id, ci); + let effectiveCi = ci; + if ( + options.skipEmpty && + (!pRes.success || !pRes.data || pRes.data.paragraphs.length === 0) && + chaptersRef.current.length > 1 + ) { + for (let i = ci + 1; i < chaptersRef.current.length; i++) { + const tryRes = await apiClient.getChapterParagraphs(book.id, i); + if ( + tryRes.success && + tryRes.data && + tryRes.data.paragraphs.length > 0 + ) { + console.log( + `[TTSScreen] Chapter ${ci} has no readable text; auto-advancing to ${i}`, + ); + effectiveCi = i; + setChapterIndex(i); + pRes = tryRes; + break; + } + } + } if (!pRes.success || !pRes.data) { setLoadError("加载章节失败"); return; @@ -519,11 +559,11 @@ export function TTSScreen() { ttsStore.setParagraphs(pRes.data.paragraphs); ttsStore.setTotalParagraphs(pRes.data.paragraphs.length); ttsStore.setChapterTitle(pRes.data.title); - ttsStore.setChapterIndex(ci); + ttsStore.setChapterIndex(effectiveCi); // Resume from saved cloud progress try { - const prog = await apiClient.getTtsProgress(book.id, ci); + const prog = await apiClient.getTtsProgress(book.id, effectiveCi); if (prog.success && prog.data && !Array.isArray(prog.data)) { const rec = prog.data; if (rec.provider) { From 15045c14ea2952877e4bd62245f10d3aace5d682 Mon Sep 17 00:00:00 2001 From: mmdctjj <984808285@qq.com> Date: Mon, 15 Jun 2026 16:43:42 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=9C=97=E8=AF=BB?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 19 +- Dockerfile | 11 + apps/server/src/config/app.config.ts | 25 ++- apps/server/src/main.ts | 10 +- .../services/cover-downloader.service.ts | 10 +- .../server/src/modules/books/books.service.ts | 199 ++++++++++++++---- docker-compose.yml | 21 +- 7 files changed, 240 insertions(+), 55 deletions(-) diff --git a/.env.example b/.env.example index 17c57461..054b17bd 100644 --- a/.env.example +++ b/.env.example @@ -32,12 +32,29 @@ JWT_REFRESH_EXPIRY=30d # ----------------------------------------------------------------------------- # Host paths for e-books, audio output, and local sources. # On NAS devices, set these to your shared folders. +# +# NAS_EBOOK_PATH accepts either a single path or a colon/comma- +# separated list of *container* paths the server scans. The +# matching *host* paths must be mounted into each container path +# separately — see NAS_EBOOK_PATHS_0/1 below. +# NAS_EBOOK_PATH=/mnt/nas1/books +# NAS_EBOOK_PATH=/data/ebooks,/data/ebooks2 +# NAS_EBOOK_PATH=/data/ebooks:/data/ebooks2 NAS_EBOOK_PATH=./data/ebooks + +# When NAS_EBOOK_PATH contains multiple container paths, set one +# host path per root. Order matters: index 0 maps to the first +# path in NAS_EBOOK_PATH, etc. Unset indices fall back to a +# `./data/ebooks` directory so a stock checkout still works. +# NAS_EBOOK_PATHS_0=/mnt/nas1/books +# NAS_EBOOK_PATHS_1=/mnt/nas2/more-books +# NAS_EBOOK_PATHS_0=./data/ebooks +# NAS_EBOOK_PATHS_1=./data/ebooks2 NAS_AUDIO_PATH=./data/audio NAS_SOURCE_PATH=./data/sources NAS_DB_PATH=./data/db -# Optional: 封面图片缓存独立存储路径(未设置时回退到 NAS_EBOOK_PATH/covers) +# Optional: 封面图片缓存独立存储路径(未设置时回退到 NAS_EBOOK_PATH[0]/covers) # CACHE_PATH=./data/cache # ----------------------------------------------------------------------------- diff --git a/Dockerfile b/Dockerfile index 9f706ff2..b697ae6e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,6 +45,17 @@ COPY apps/desktop ./apps/desktop COPY packages ./packages COPY tsconfig.json ./ +# Re-run pnpm install offline to fix any dangling symlinks left over +# from the deps stage. With `shamefully-hoist=true` in .npmrc, pnpm +# can leave symlinks under `apps/desktop/node_modules/` pointing +# at a `.pnpm/@/` directory that the lockfile never +# actually populated (the real package lives at +# `.pnpm/@_/node_modules//`). Those dangling +# symlinks break `require('tailwindcss')` and friends from the +# postcss / vite plugins. Re-installing against the same lockfile +# (offline, frozen) reconciles the symlinks without re-downloading. +RUN pnpm install --offline --frozen-lockfile + # Build the web app (browser mode) RUN pnpm --filter @bookdock/desktop exec vite build diff --git a/apps/server/src/config/app.config.ts b/apps/server/src/config/app.config.ts index 29f35062..02dc3d56 100644 --- a/apps/server/src/config/app.config.ts +++ b/apps/server/src/config/app.config.ts @@ -35,6 +35,25 @@ function resolveJwtSecret(): string { return generated; } +/** + * Parse NAS_EBOOK_PATH into a deduplicated, ordered list of absolute + * filesystem paths. Accepts both `:` (POSIX standard, recommended) + * and `,` (more familiar to .env authors on Windows) as separators + * so existing single-path deployments keep working unchanged. + */ +function parseEbookPaths(raw: string | undefined): string[] { + const fallback = '/data/ebooks'; + if (!raw) return [fallback]; + return Array.from( + new Set( + raw + .split(/[:,]/) + .map((p) => p.trim()) + .filter(Boolean), + ), + ); +} + export const AppConfig = registerAs('app', () => ({ nodeEnv: process.env.NODE_ENV || 'development', port: parseInt(process.env.PORT || '8088', 10), @@ -43,7 +62,11 @@ export const AppConfig = registerAs('app', () => ({ jwtRefreshExpiry: process.env.JWT_REFRESH_EXPIRY || '30d', apiBaseUrl: process.env.API_BASE_URL || 'http://localhost:8088', corsOrigins: process.env.CORS_ORIGINS || '*', - nasEbookPath: process.env.NAS_EBOOK_PATH || '/data/ebooks', + // NAS_EBOOK_PATH accepts a single path or a colon/comma-separated + // list. Internally we always treat it as an array; the first entry + // is the "primary" root used for new uploads and as the cover + // directory fallback. + nasEbookPaths: parseEbookPaths(process.env.NAS_EBOOK_PATH), nasAudioPath: process.env.NAS_AUDIO_PATH || '/data/audio', sourceLocalPath: process.env.SOURCE_LOCAL_PATH || '/data/sources', cachePath: process.env.CACHE_PATH || '', diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 83964a0e..f6f6d4e9 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -17,9 +17,15 @@ async function bootstrap() { app.use(express.urlencoded({ extended: true, limit: '50mb' })); // Serve cover images statically (before global prefix, before ServeStaticModule) - // 优先使用 CACHE_PATH,未设置时回退到 NAS_EBOOK_PATH/covers + // 优先使用 CACHE_PATH,未设置时回退到 NAS_EBOOK_PATH(第一个根)的 covers 子目录。 + // NAS_EBOOK_PATH 支持单路径或多路径(: 或 , 分隔),封面统一放在主根下。 const cachePath = process.env.CACHE_PATH; - const nasEbookPath = resolve(process.env.NAS_EBOOK_PATH || '/data/ebooks'); + const nasEbookPath = resolve( + (process.env.NAS_EBOOK_PATH || '/data/ebooks') + .split(/[:,]/) + .map((p) => p.trim()) + .filter(Boolean)[0] || '/data/ebooks', + ); const coversPath = cachePath ? join(resolve(cachePath), 'covers') : join(nasEbookPath, 'covers'); diff --git a/apps/server/src/modules/book-metadata/services/cover-downloader.service.ts b/apps/server/src/modules/book-metadata/services/cover-downloader.service.ts index 41d284f3..320ad06a 100644 --- a/apps/server/src/modules/book-metadata/services/cover-downloader.service.ts +++ b/apps/server/src/modules/book-metadata/services/cover-downloader.service.ts @@ -1,9 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; -import * as sharp from 'sharp'; import { promises as fs } from 'fs'; import * as path from 'path'; +import * as sharp from 'sharp'; export interface CoverDownloadResult { localPath: string; // 本地绝对路径 @@ -18,14 +18,18 @@ export class CoverDownloaderService { /** * 获取封面存储目录的绝对路径 - * 优先使用 CACHE_PATH,未设置时回退到 NAS_EBOOK_PATH/covers + * 优先使用 CACHE_PATH,未设置时回退到 NAS_EBOOK_PATH(第一个根)的 covers 子目录。 + * 多个 NAS 根时封面统一放在主根下,避免分裂到多个目录。 */ private getCoversDir(): string { const cachePath = this.configService.get('app.cachePath'); if (cachePath) { return path.join(cachePath, 'covers'); } - const nasPath = this.configService.get('app.nasEbookPath') || '/data/ebooks'; + const nasPaths = this.configService.get('app.nasEbookPaths'); + const nasPath = (Array.isArray(nasPaths) && nasPaths.length > 0) + ? nasPaths[0] + : (this.configService.get('app.nasEbookPath') || '/data/ebooks'); return path.join(nasPath, 'covers'); } diff --git a/apps/server/src/modules/books/books.service.ts b/apps/server/src/modules/books/books.service.ts index 91aceb8c..f05c2590 100644 --- a/apps/server/src/modules/books/books.service.ts +++ b/apps/server/src/modules/books/books.service.ts @@ -1,6 +1,7 @@ import { Inject, Injectable, + Logger, NotFoundException, OnModuleInit } from '@nestjs/common'; @@ -43,7 +44,9 @@ async function getMobiParser() { @Injectable() export class BooksService implements OnModuleInit { - private readonly nasEbookPath: string; + private readonly logger = new Logger(BooksService.name); + private readonly nasEbookPaths: string[]; + private readonly primaryEbookPath: string; private readonly apiBaseUrl: string; constructor( @@ -51,8 +54,59 @@ export class BooksService implements OnModuleInit { private readonly configService: ConfigService, private readonly metadataService?: BookMetadataService, // T7: optional metadata service ) { - this.nasEbookPath = this.configService.get('app.nasEbookPath') || '/data/ebooks'; + // NAS_EBOOK_PATH may be a single path or a colon/comma-separated + // list. We always treat it as an array; the first entry is the + // "primary" root used for new uploads. + const configured = + this.configService.get('app.nasEbookPaths') || + this.configService.get('app.nasEbookPath') || // legacy single-string form + '/data/ebooks'; + this.nasEbookPaths = Array.isArray(configured) + ? (configured.length > 0 ? configured : ['/data/ebooks']) + : [configured]; + this.primaryEbookPath = this.nasEbookPaths[0]; this.apiBaseUrl = this.configService.get('app.apiBaseUrl') || 'http://localhost:3000'; + + // Log the parsed roots so misconfigurations (typo, missing + // volume mount) are immediately visible in `docker logs`. + this.logger.log( + `NAS_EBOOK_PATH resolved to ${this.nasEbookPaths.length} root(s): ${JSON.stringify(this.nasEbookPaths)}`, + ); + for (const root of this.nasEbookPaths) { + if (existsSync(root)) { + this.logger.log(` [ok] ${root}`); + } else { + this.logger.warn( + ` [MISS] ${root} — directory not found, scan will skip it. ` + + `If this is unexpected, check the volume mount in docker-compose.yml: ` + + `for multi-root setups you need a separate host→container bind per root.`, + ); + } + } + } + + /** + * Resolve a relative `filePath` (as stored on the Book row) to an + * absolute path on disk. Order of resolution: + * 1. filePath → root cache populated by the most recent scan. + * 2. First configured root where `filePath` exists. + * 3. `join(primaryEbookPath, filePath)` — used so callers can + * surface a clear "file not found" error from a stable path. + */ + private resolveEbookPath(filePath: string): string { + const cached = this.filePathRootCache.get(filePath); + if (cached) { + const candidate = join(cached, filePath); + if (existsSync(candidate)) return candidate; + } + for (const root of this.nasEbookPaths) { + const candidate = join(root, filePath); + if (existsSync(candidate)) { + this.filePathRootCache.set(filePath, root); + return candidate; + } + } + return join(this.primaryEbookPath, filePath); } onModuleInit() { @@ -66,7 +120,7 @@ export class BooksService implements OnModuleInit { let fileHash: string | undefined; let fileSize: bigint | undefined; - const fullPath = join(this.nasEbookPath, dto.filePath); + const fullPath = this.resolveEbookPath(dto.filePath); if (existsSync(fullPath)) { try { const crypto = await import('crypto'); @@ -120,7 +174,7 @@ export class BooksService implements OnModuleInit { throw new Error(`不支持的文件格式: ${ext}`); } - const destPath = join(this.nasEbookPath, originalname); + const destPath = join(this.primaryEbookPath, originalname); // If file already exists, append a number let finalFileName = originalname; @@ -129,7 +183,7 @@ export class BooksService implements OnModuleInit { while (existsSync(finalDestPath)) { const nameWithoutExt = originalname.replace(/\.[^/.]+$/, ''); finalFileName = `${nameWithoutExt} (${counter}).${ext}`; - finalDestPath = join(this.nasEbookPath, finalFileName); + finalDestPath = join(this.primaryEbookPath, finalFileName); counter++; } @@ -195,20 +249,39 @@ export class BooksService implements OnModuleInit { return this.toBookResponse(book); } + /** + * Map of `filePath` (as stored on Book) → root directory that first + * surfaced that path during the most recent scan. Used by + * `resolveEbookPath` to look up the correct absolute path on disk + * even after server restarts. Entries are written by + * `collectEbookFilesRecursively` and read by `resolveEbookPath`. + * + * On a server restart the map starts empty; missing entries fall + * through to "first existing root" / primary root, which is + * correct for the common single-root deployment and still works + * when the user has multiple roots with non-overlapping file names. + */ + private readonly filePathRootCache = new Map(); + async scanLocalBooks(): Promise { const ebookExts = ['txt', 'epub', 'pdf', 'mobi', 'azw3', 'fb2', 'djvu']; let added = 0; try { - const entries = await this.collectEbookFilesRecursively(this.nasEbookPath, ebookExts); - for (const filePath of entries) { + const entries = await this.collectEbookFilesRecursively(ebookExts); + for (const { filePath, rootPath } of entries) { const ext = filePath.split('.').pop()?.toLowerCase() || ''; const existing = await this.prisma.book.findFirst({ where: { filePath, isDeleted: false }, }); - if (existing) continue; + if (existing) { + // Refresh root cache from re-scan results (e.g. user moved + // the file to a different root and restarted the server). + this.filePathRootCache.set(filePath, rootPath); + continue; + } - const fullPath = join(this.nasEbookPath, filePath); + const fullPath = join(rootPath, filePath); const fileStat = await stat(fullPath); const fileName = filePath.split(/[\\/]/).pop() || filePath; const title = fileName.replace(/\.[^/.]+$/, '').replace(/[_-]/g, ' '); @@ -224,6 +297,7 @@ export class BooksService implements OnModuleInit { metadata: '{}', }, }); + this.filePathRootCache.set(filePath, rootPath); // T7: auto metadata fetch if (this.metadataService) { @@ -239,28 +313,62 @@ export class BooksService implements OnModuleInit { return added; } - private async collectEbookFilesRecursively(basePath: string, ebookExts: string[], relativePath = ''): Promise { - const currentPath = relativePath ? join(basePath, relativePath) : basePath; - const entries = await readdir(currentPath, { withFileTypes: true }); - const files: string[] = []; - - for (const entry of entries) { - const nextRelativePath = relativePath ? join(relativePath, entry.name) : entry.name; - if (entry.isDirectory()) { - const nestedFiles = await this.collectEbookFilesRecursively(basePath, ebookExts, nextRelativePath); - files.push(...nestedFiles); + /** + * Walk every configured NAS root in priority order and return the + * list of ebook files (with the root that surfaced each one). On + * collisions — same relative path in two roots — the earlier root + * wins and the later one is skipped, so the on-disk canonical + * location is deterministic and matches the user's primary root. + */ + private async collectEbookFilesRecursively( + ebookExts: string[], + rootOverride?: string, + relativePath = '', + ): Promise<{ filePath: string; rootPath: string }[]> { + const roots = rootOverride ? [rootOverride] : this.nasEbookPaths; + const seen = new Set(); + const out: { filePath: string; rootPath: string }[] = []; + + for (const root of roots) { + let entries: import('fs').Dirent[]; + try { + const currentPath = relativePath ? join(root, relativePath) : root; + entries = await readdir(currentPath, { withFileTypes: true }); + } catch { + // Root doesn't exist or isn't readable; skip it. Single-root + // setups keep working because the default '/data/ebooks' is + // the only entry and is allowed to be missing in dev. continue; } - if (!entry.isFile()) continue; + for (const entry of entries) { + const nextRelativePath = relativePath + ? join(relativePath, entry.name) + : entry.name; + if (entry.isDirectory()) { + const nested = await this.collectEbookFilesRecursively( + ebookExts, + root, + nextRelativePath, + ); + for (const f of nested) { + if (seen.has(f.filePath)) continue; + seen.add(f.filePath); + out.push(f); + } + continue; + } - const ext = entry.name.split('.').pop()?.toLowerCase() || ''; - if (ebookExts.includes(ext)) { - files.push(nextRelativePath); + if (!entry.isFile()) continue; + const ext = entry.name.split('.').pop()?.toLowerCase() || ''; + if (!ebookExts.includes(ext)) continue; + if (seen.has(nextRelativePath)) continue; + seen.add(nextRelativePath); + out.push({ filePath: nextRelativePath, rootPath: root }); } } - return files; + return out; } @@ -281,7 +389,7 @@ export class BooksService implements OnModuleInit { } private async parseTxtChapters(filePath: string): Promise<{ title: string; startLine: number }[]> { - const fullPath = join(this.nasEbookPath, filePath); + const fullPath = this.resolveEbookPath(filePath); if (!existsSync(fullPath)) return []; const text = await this.readTextFile(fullPath); @@ -371,7 +479,7 @@ export class BooksService implements OnModuleInit { throw new NotFoundException('Chapter not found'); } - const fullPath = join(this.nasEbookPath, filePath); + const fullPath = this.resolveEbookPath(filePath); const text = await this.readTextFile(fullPath); const lines = text.split(/\r?\n/); @@ -392,7 +500,7 @@ export class BooksService implements OnModuleInit { // ─── EPUB Parsing ──────────────────────────────────────────────────────── private async parseEpubChapters(filePath: string): Promise<{ title: string; id: string; index: number }[]> { - const fullPath = join(this.nasEbookPath, filePath); + const fullPath = this.resolveEbookPath(filePath); if (!existsSync(fullPath)) return []; try { @@ -500,7 +608,7 @@ export class BooksService implements OnModuleInit { filePath: string, chapterIndex: number, ): Promise<{ title: string; paragraphs: BookParagraph[] }> { - const fullPath = join(this.nasEbookPath, filePath); + const fullPath = this.resolveEbookPath(filePath); if (!existsSync(fullPath)) { throw new NotFoundException('EPUB file not found'); } @@ -534,7 +642,7 @@ export class BooksService implements OnModuleInit { if (chapterIndex < 0 || chapterIndex >= chapters.length) { throw new NotFoundException('Chapter not found'); } - const fullPath = join(this.nasEbookPath, filePath); + const fullPath = this.resolveEbookPath(filePath); const text = await this.readTextFile(fullPath); const lines = text.split(/\r?\n/); const startLine = chapters[chapterIndex].startLine; @@ -560,7 +668,7 @@ export class BooksService implements OnModuleInit { chapterIndex: number, ): Promise<{ title: string; paragraphs: BookParagraph[] }> { let mobi: any; - const fullPath = join(this.nasEbookPath, filePath); + const fullPath = this.resolveEbookPath(filePath); if (!existsSync(fullPath)) throw new NotFoundException('MOBI/AZW3 file not found'); try { const parser = await getMobiParser(); @@ -606,7 +714,7 @@ export class BooksService implements OnModuleInit { } private async getEpubChapterContent(filePath: string, chapterIndex: number): Promise<{ title: string; content: string }> { - const fullPath = join(this.nasEbookPath, filePath); + const fullPath = this.resolveEbookPath(filePath); if (!existsSync(fullPath)) { throw new NotFoundException('EPUB file not found'); } @@ -798,7 +906,7 @@ export class BooksService implements OnModuleInit { // ─── MOBI / AZW3 Parsing ───────────────────────────────────────────────── private async parseMobiChapters(filePath: string): Promise<{ title: string; id: string; index: number }[]> { - const fullPath = join(this.nasEbookPath, filePath); + const fullPath = this.resolveEbookPath(filePath); if (!existsSync(fullPath)) return []; let mobi: any; @@ -853,7 +961,7 @@ export class BooksService implements OnModuleInit { } private async getMobiChapterContent(filePath: string, chapterIndex: number): Promise<{ title: string; content: string }> { - const fullPath = join(this.nasEbookPath, filePath); + const fullPath = this.resolveEbookPath(filePath); if (!existsSync(fullPath)) { throw new NotFoundException('MOBI/AZW3 file not found'); } @@ -1043,9 +1151,9 @@ export class BooksService implements OnModuleInit { let updated = 0; try { - // 1. Collect all files on disk - const entries = await this.collectEbookFilesRecursively(this.nasEbookPath, ebookExts); - const filePathsOnDisk = new Set(entries); + // 1. Collect all files on disk (across every configured NAS root) + const entries = await this.collectEbookFilesRecursively(ebookExts); + const filePathsOnDisk = new Set(entries.map((e) => e.filePath)); // 2. Get all existing books from DB const existingBooks = await this.prisma.book.findMany({ @@ -1055,11 +1163,11 @@ export class BooksService implements OnModuleInit { const existingPaths = new Map(existingBooks.map((b) => [b.filePath, b])); // 3. Add new books - for (const filePath of entries) { + for (const { filePath, rootPath } of entries) { if (existingPaths.has(filePath)) continue; const ext = filePath.split('.').pop()?.toLowerCase() || ''; - const fullPath = join(this.nasEbookPath, filePath); + const fullPath = join(rootPath, filePath); const fileStat = await stat(fullPath); const fileName = filePath.split(/[\\/]/).pop() || filePath; const title = fileName.replace(/\.[^/.]+$/, '').replace(/[_-]/g, ' '); @@ -1075,6 +1183,7 @@ export class BooksService implements OnModuleInit { metadata: '{}', }, }); + this.filePathRootCache.set(filePath, rootPath); if (this.metadataService) { this.metadataService.fetchAndUpdateBook(createdBook.id).catch(() => {}); @@ -1115,16 +1224,19 @@ export class BooksService implements OnModuleInit { let added = 0; try { - const entries = await this.collectEbookFilesRecursively(this.nasEbookPath, ebookExts); + const entries = await this.collectEbookFilesRecursively(ebookExts); - for (const filePath of entries) { + for (const { filePath, rootPath } of entries) { const existing = await this.prisma.book.findFirst({ where: { filePath, isDeleted: false }, }); - if (existing) continue; + if (existing) { + this.filePathRootCache.set(filePath, rootPath); + continue; + } const ext = filePath.split('.').pop()?.toLowerCase() || ''; - const fullPath = join(this.nasEbookPath, filePath); + const fullPath = join(rootPath, filePath); const fileStat = await stat(fullPath); const fileName = filePath.split(/[\\/]/).pop() || filePath; const title = fileName.replace(/\.[^/.]+$/, '').replace(/[_-]/g, ' '); @@ -1140,6 +1252,7 @@ export class BooksService implements OnModuleInit { metadata: '{}', }, }); + this.filePathRootCache.set(filePath, rootPath); if (this.metadataService) { this.metadataService.fetchAndUpdateBook(createdBook.id).catch(() => {}); @@ -1201,7 +1314,7 @@ export class BooksService implements OnModuleInit { }; return { - path: join(this.nasEbookPath, book.filePath), + path: this.resolveEbookPath(book.filePath), filename: `${book.title}.${book.format}`, contentType: formatMimeTypes[book.format as BookFormat], }; diff --git a/docker-compose.yml b/docker-compose.yml index d9991945..8b5d4240 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,17 +17,28 @@ services: PORT: 8088 # SQLite database (persisted in volume) DATABASE_URL: file:/data/db/bookdock.db - # NAS / data paths inside container - NAS_EBOOK_PATH: /data/ebooks + # NAS / data paths inside container. NAS_EBOOK_PATH is a + # colon/comma-separated list of *container* paths the server + # scans. The matching *host* paths come from NAS_EBOOK_PATHS_0/1 + # (one per root) so each container path can be bound to a + # distinct host directory — `${NAS_EBOOK_PATH}` is a single + # string and would be passed to docker as a single host path, + # which is not what we want. + NAS_EBOOK_PATH: /data/ebooks,/data/ebooks2 CACHE_PATH: /data/covers # MiMo TTS provider (optional — fill in to enable Xiaomi TTS) TTS_MIMO_API_TOKEN: sk-xxxx - TTS_MIMO_MODEL: mimo-v2.5-tts + TTS_MIMO_MODEL: mimo-v2-tts volumes: # Database persistence - bookdock-db:/data/db - # E-book library (read-only recommended if scanning) - - ${NAS_EBOOK_PATH:-./data/ebooks}:/data/ebooks + # E-book library. Each container path declared in + # NAS_EBOOK_PATH needs its own host→container bind. Override + # NAS_EBOOK_PATHS_0/1 in your shell or .env to point at real + # host directories; the defaults below only exist so a stock + # `docker compose up` still works on a fresh checkout. + - ${NAS_EBOOK_PATHS_0:-./data/ebooks}:/data/ebooks + - ${NAS_EBOOK_PATHS_1:-./data/ebooks2}:/data/ebooks2 # Cover cache (read-only recommended) - ${CACHE_PATH:-./data/covers}:/data/covers