diff --git a/apps/admin/src/api/ai.ts b/apps/admin/src/api/ai.ts index 2d4da2d9326..5a0893d9f1c 100644 --- a/apps/admin/src/api/ai.ts +++ b/apps/admin/src/api/ai.ts @@ -405,3 +405,66 @@ export function updateTranslationEntry( export function deleteTranslationEntry(id: string) { return deleteJson(`/ai/translations/entries/${id}`) } + +export interface AITtsSegment { + blockId: string + chunkIndex: number + text: string + url: string +} + +export interface AITtsRow { + blockOrder: string[] + charCount: number + id: string + isTranslation: boolean + lang: string + model: string + refId: string + segments: AITtsSegment[] + speed: number + updatedAt?: null | string + voice: string +} + +export function createTtsTask(data: { + force?: boolean + langs?: string[] + refId: string +}) { + return postJson('/ai/tts/task', data) +} + +export interface TtsByRefResponse { + article: { + document: { title: string } + type: 'Note' | 'Page' | 'Post' | 'Recently' + } | null + rows: AITtsRow[] +} + +export function getTtsByRefId(refId: string) { + return getJson(`/ai/tts/ref/${refId}`) +} + +export function deleteTts(id: string) { + return deleteJson(`/ai/tts/${id}`) +} + +export interface GroupedTtsData { + article: ArticleInfo + narrations: AITtsRow[] +} + +export interface GroupedTtsResponse { + data: GroupedTtsData[] + pagination: PaginationInfo +} + +export function getTtsGrouped(params?: { + page?: number + search?: string + size?: number +}) { + return getJson('/ai/tts/grouped', params) +} diff --git a/apps/admin/src/api/http.test.ts b/apps/admin/src/api/http.test.ts index 3b10587b2d2..6329b5028a7 100644 --- a/apps/admin/src/api/http.test.ts +++ b/apps/admin/src/api/http.test.ts @@ -50,3 +50,48 @@ describe('requestJson error handling', () => { expect((error as ApiRequestError).code).toBeUndefined() }) }) + +describe('requestJson meta unwrapping', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('flattens pagination alongside data', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + jsonResponse({ + data: [{ id: '1' }], + meta: { pagination: { page: 1, size: 10, total: 1, totalPages: 1 } }, + }), + ) + + const result = await getJson<{ + data: unknown[] + pagination: { total: number } + }>('/ai/tts') + + expect(result.data).toEqual([{ id: '1' }]) + expect(result.pagination.total).toBe(1) + }) + + it('carries sibling meta fields (e.g. articles) alongside pagination', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + jsonResponse({ + data: [{ id: '1', ref_id: '9' }], + meta: { + articles: { '9': { id: '9', title: 'Hello', type: 'Post' } }, + pagination: { page: 1, size: 10, total: 1, totalPages: 1 }, + }, + }), + ) + + const result = await getJson<{ + articles: Record + data: unknown[] + pagination: { total: number } + }>('/ai/tts') + + expect(result.articles).toEqual({ + '9': { id: '9', title: 'Hello', type: 'Post' }, + }) + }) +}) diff --git a/apps/admin/src/api/http.ts b/apps/admin/src/api/http.ts index d490400a0b3..7e383516c76 100644 --- a/apps/admin/src/api/http.ts +++ b/apps/admin/src/api/http.ts @@ -13,6 +13,7 @@ type ResponseEnvelope = { } meta?: { pagination?: unknown + [key: string]: unknown } message?: string | string[] } @@ -94,9 +95,11 @@ export async function requestJson( if (responseData && 'data' in responseData) { if (responseData.meta?.pagination) { + const { pagination, ...restMeta } = responseData.meta return { data: responseData.data, - pagination: responseData.meta.pagination, + pagination, + ...restMeta, } as TResponse } diff --git a/apps/admin/src/api/tasks.ts b/apps/admin/src/api/tasks.ts index 0ecb6049361..ad6c7b58087 100644 --- a/apps/admin/src/api/tasks.ts +++ b/apps/admin/src/api/tasks.ts @@ -11,6 +11,7 @@ export enum AITaskType { Insights = 'ai:insights', InsightsTranslation = 'ai:insights:translation', ImageGeneration = 'ai:image:generation', + Tts = 'ai:tts', } export enum AITaskStatus { diff --git a/apps/admin/src/features/_shared/components/tts/TtsSegmentPlayer.tsx b/apps/admin/src/features/_shared/components/tts/TtsSegmentPlayer.tsx new file mode 100644 index 00000000000..3cda1825dd4 --- /dev/null +++ b/apps/admin/src/features/_shared/components/tts/TtsSegmentPlayer.tsx @@ -0,0 +1,142 @@ +import { Pause, Play, RefreshCw, Square } from 'lucide-react' +import { useEffect, useMemo, useRef } from 'react' + +import type { AITtsSegment } from '~/api/ai' +import { useI18n } from '~/i18n' +import { Button } from '~/ui/primitives/button' +import { cn } from '~/utils/cn' + +import { useTtsPlayback } from './use-tts-playback' + +export function TtsSegmentPlayer(props: { + segments: AITtsSegment[] + onRegenerate?: () => void + regenerateSubmitting?: boolean +}) { + const { t } = useI18n() + const urls = useMemo( + () => props.segments.map((segment) => segment.url), + [props.segments], + ) + const playback = useTtsPlayback(urls) + const itemRefs = useRef<(HTMLDivElement | null)[]>([]) + + useEffect(() => { + if (playback.playingIndex === null) return + itemRefs.current[playback.playingIndex]?.scrollIntoView({ + block: 'nearest', + }) + }, [playback.playingIndex]) + + if (props.segments.length === 0) { + return ( +

{t('ttsPlayer.empty')}

+ ) + } + + return ( +
+
+ {playback.playingIndex !== null ? ( + + {t('ttsPlayer.progress', { + current: playback.playingIndex + 1, + total: props.segments.length, + })} + + ) : null} + {props.onRegenerate ? ( + + ) : null} + + {playback.playingIndex !== null ? ( + + ) : null} +
+ +
+ +
+ ) +} diff --git a/apps/admin/src/features/_shared/components/tts/use-tts-playback.test.tsx b/apps/admin/src/features/_shared/components/tts/use-tts-playback.test.tsx new file mode 100644 index 00000000000..f059257d9bf --- /dev/null +++ b/apps/admin/src/features/_shared/components/tts/use-tts-playback.test.tsx @@ -0,0 +1,227 @@ +import { act, createElement } from 'react' +import type { Root } from 'react-dom/client' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useTtsPlayback } from './use-tts-playback' + +type Listener = () => void + +class MockAudio { + static instances: MockAudio[] = [] + + currentTime = 0 + paused = true + preload = '' + src = '' + + private listeners = new Map>() + + addEventListener = (type: string, listener: Listener) => { + const set = this.listeners.get(type) ?? new Set() + set.add(listener) + this.listeners.set(type, set) + } + + load = vi.fn() + + pause = vi.fn(() => { + this.paused = true + this.emit('pause') + }) + + play = vi.fn(() => { + this.paused = false + this.emit('play') + return Promise.resolve() + }) + + removeAttribute = vi.fn() + + constructor() { + MockAudio.instances.push(this) + } + + emit(type: string) { + this.listeners.get(type)?.forEach((listener) => listener()) + } +} + +const URLS = [ + 'https://example.com/segment-1.mp3', + 'https://example.com/segment-2.mp3', + 'https://example.com/segment-3.mp3', +] + +interface Harness { + root: Root + unmount: () => void +} + +function mount(): Harness { + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + let mounted = true + return { + root, + unmount: () => { + if (!mounted) return + mounted = false + act(() => { + root.unmount() + }) + container.remove() + }, + } +} + +let latest: ReturnType | undefined + +function Probe(props: { urls: string[] }) { + latest = useTtsPlayback(props.urls) + return null +} + +let harness: Harness + +beforeEach(() => { + MockAudio.instances = [] + vi.stubGlobal('Audio', MockAudio) + latest = undefined + harness = mount() + act(() => { + harness.root.render(createElement(Probe, { urls: URLS })) + }) +}) + +afterEach(() => { + harness.unmount() + vi.unstubAllGlobals() + document.body.innerHTML = '' +}) + +function audio() { + return MockAudio.instances[0] +} + +describe('useTtsPlayback', () => { + it('starts from the first segment on playAll', () => { + act(() => { + latest!.playAll() + }) + + expect(audio().src).toBe(URLS[0]) + expect(audio().play).toHaveBeenCalledTimes(1) + expect(latest!.playingIndex).toBe(0) + expect(latest!.isPlaying).toBe(true) + }) + + it('advances to the next segment when the current one ends', () => { + act(() => { + latest!.playAll() + }) + act(() => { + audio().emit('ended') + }) + + expect(audio().src).toBe(URLS[1]) + expect(audio().play).toHaveBeenCalledTimes(2) + expect(latest!.playingIndex).toBe(1) + expect(latest!.isPlaying).toBe(true) + }) + + it('resets after the last segment ends', () => { + act(() => { + latest!.playAll() + }) + act(() => { + audio().emit('ended') + }) + act(() => { + audio().emit('ended') + }) + expect(latest!.playingIndex).toBe(2) + + act(() => { + audio().emit('ended') + }) + expect(latest!.playingIndex).toBe(null) + expect(latest!.isPlaying).toBe(false) + }) + + it('pauses and resumes the current segment on toggle', () => { + act(() => { + latest!.toggleSegment(1) + }) + expect(audio().src).toBe(URLS[1]) + + act(() => { + latest!.toggleSegment(1) + }) + expect(audio().pause).toHaveBeenCalledTimes(1) + expect(latest!.playingIndex).toBe(1) + expect(latest!.isPlaying).toBe(false) + + act(() => { + latest!.toggleSegment(1) + }) + expect(audio().play).toHaveBeenCalledTimes(2) + expect(latest!.isPlaying).toBe(true) + }) + + it('switches the segment when toggling a different index', () => { + act(() => { + latest!.toggleSegment(0) + }) + act(() => { + latest!.toggleSegment(2) + }) + + expect(audio().src).toBe(URLS[2]) + expect(latest!.playingIndex).toBe(2) + expect(latest!.isPlaying).toBe(true) + }) + + it('clears progress on stop', () => { + act(() => { + latest!.toggleSegment(1) + }) + act(() => { + latest!.stop() + }) + + expect(audio().pause).toHaveBeenCalled() + expect(latest!.playingIndex).toBe(null) + expect(latest!.isPlaying).toBe(false) + }) + + it('stops playback when the urls change underneath it', () => { + act(() => { + latest!.playAll() + }) + act(() => { + harness.root.render( + createElement(Probe, { + urls: ['https://example.com/other-1.mp3'], + }), + ) + }) + + expect(audio().pause).toHaveBeenCalled() + expect(latest!.playingIndex).toBe(null) + expect(latest!.isPlaying).toBe(false) + }) + + it('releases the audio element on unmount', () => { + act(() => { + latest!.playAll() + }) + const element = audio() + + harness.unmount() + + expect(element.pause).toHaveBeenCalled() + expect(element.removeAttribute).toHaveBeenCalledWith('src') + }) +}) diff --git a/apps/admin/src/features/_shared/components/tts/use-tts-playback.ts b/apps/admin/src/features/_shared/components/tts/use-tts-playback.ts new file mode 100644 index 00000000000..3deb61c918c --- /dev/null +++ b/apps/admin/src/features/_shared/components/tts/use-tts-playback.ts @@ -0,0 +1,133 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +export interface TtsPlayback { + isPlaying: boolean + playAll: () => void + playingIndex: null | number + stop: () => void + toggleSegment: (index: number) => void +} + +/** + * Drives a single `Audio` element through a list of segment urls so only one + * voice can sound at a time. `ended` auto-advances to the next segment, + * which makes "play all" a faithful way to audit the seams between segments. + */ +export function useTtsPlayback(urls: string[]): TtsPlayback { + const audioRef = useRef(null) + const urlsRef = useRef(urls) + const playingIndexRef = useRef(null) + // The `ended` listener is attached once when the Audio element is created, + // so it reaches the current playFrom through this ref instead of closing + // over a stale one. + const playFromRef = useRef<(index: number) => void>(() => undefined) + + const [playingIndex, setPlayingIndex] = useState(null) + const [isPlaying, setIsPlaying] = useState(false) + + const setCurrentIndex = useCallback((index: null | number) => { + playingIndexRef.current = index + setPlayingIndex(index) + }, []) + + const stop = useCallback(() => { + const audio = audioRef.current + if (audio) { + audio.pause() + audio.currentTime = 0 + } + setCurrentIndex(null) + }, [setCurrentIndex]) + + const playFrom = useCallback( + (index: number) => { + const url = urlsRef.current[index] + if (!url) return + + let audio = audioRef.current + if (!audio) { + audio = new Audio() + audio.preload = 'auto' + audio.addEventListener('play', () => setIsPlaying(true)) + audio.addEventListener('pause', () => setIsPlaying(false)) + audio.addEventListener('ended', () => { + const current = playingIndexRef.current + if (current !== null && current + 1 < urlsRef.current.length) { + playFromRef.current(current + 1) + } else { + setCurrentIndex(null) + setIsPlaying(false) + } + }) + audio.addEventListener('error', () => { + setCurrentIndex(null) + setIsPlaying(false) + }) + audioRef.current = audio + } + + setCurrentIndex(index) + // Always reassign src: replaying the same segment should restart it. + audio.src = url + const playPromise = audio.play() + if (playPromise) { + playPromise.catch(() => { + setCurrentIndex(null) + setIsPlaying(false) + }) + } + }, + [setCurrentIndex], + ) + + useEffect(() => { + playFromRef.current = playFrom + }, [playFrom]) + + // Keep the live url list in sync for the `ended` listener. If the urls + // change underneath an in-flight playback (language switch, regenerated + // segments), stop rather than keep playing a segment that no longer + // matches the list. + useEffect(() => { + const previous = urlsRef.current + urlsRef.current = urls + const current = playingIndexRef.current + if (current !== null && previous[current] !== urls[current]) { + stop() + } + }, [urls, stop]) + + useEffect(() => { + return () => { + const audio = audioRef.current + if (!audio) return + audio.pause() + audio.removeAttribute('src') + if (typeof audio.load === 'function') audio.load() + audioRef.current = null + } + }, []) + + const playAll = useCallback(() => { + playFrom(0) + }, [playFrom]) + + const toggleSegment = useCallback( + (index: number) => { + const audio = audioRef.current + if (playingIndexRef.current === index && audio) { + if (audio.paused) { + const playPromise = audio.play() + if (playPromise) playPromise.catch(() => undefined) + } else { + audio.pause() + } + return + } + playFrom(index) + }, + [playFrom], + ) + + return { isPlaying, playAll, playingIndex, stop, toggleSegment } +} diff --git a/apps/admin/src/features/ai/components/article-grouped/ArticleDetailPane.tsx b/apps/admin/src/features/ai/components/article-grouped/ArticleDetailPane.tsx index d0edaf831d5..046cc137f72 100644 --- a/apps/admin/src/features/ai/components/article-grouped/ArticleDetailPane.tsx +++ b/apps/admin/src/features/ai/components/article-grouped/ArticleDetailPane.tsx @@ -100,14 +100,9 @@ export function ArticleDetailPane(props: ArticleDetailPaneProps) {
-
-

- {t(props.config.detailSectionTitleKey)} -

- - {t(props.config.itemCountKey, { count: props.items.length })} - -
+ + {t(props.config.itemCountKey, { count: props.items.length })} + {props.isLoading && props.items.length === 0 ? (
diff --git a/apps/admin/src/features/ai/components/article-grouped/TtsPlaybackBody.tsx b/apps/admin/src/features/ai/components/article-grouped/TtsPlaybackBody.tsx new file mode 100644 index 00000000000..3bc609ba5a6 --- /dev/null +++ b/apps/admin/src/features/ai/components/article-grouped/TtsPlaybackBody.tsx @@ -0,0 +1,60 @@ +import { useMutation } from '@tanstack/react-query' +import { toast } from 'sonner' + +import type { AITtsRow } from '~/api/ai' +import { TtsSegmentPlayer } from '~/features/_shared/components/tts/TtsSegmentPlayer' +import { useI18n } from '~/i18n' + +import { getErrorMessage } from '../../utils/ai' +import { useArticleGroupedRouteContext } from './article-grouped-route-context' +import type { EditDrawerBodyProps } from './types' + +export function TtsPlaybackBody(props: EditDrawerBodyProps) { + const { t } = useI18n() + const { item } = props + const ctx = useArticleGroupedRouteContext() + + const regenerateAction = ctx.config + .extraItemActions?.(item) + .find((a) => a.id === 'regenerate') + + const regenerateMutation = useMutation({ + mutationFn: async () => { + if (!regenerateAction) return + await regenerateAction.run(item) + }, + onError: (error: unknown) => + toast.error(getErrorMessage(error, t('ai.toast.taskCreateFailed'))), + onSuccess: async () => { + toast.success(t('ai.toast.taskCreated')) + await ctx.invalidate() + }, + }) + + return ( +
+
+
+ + {item.lang.toUpperCase()} + + + {item.segments.length} {t('ttsPlayer.segmentsTitle')} ·{' '} + {item.charCount} {t('ai.tts.charCountLabel')} + +
+

+ {item.model} · {item.voice} · {item.speed}x +

+ + regenerateMutation.mutate() : undefined + } + regenerateSubmitting={regenerateMutation.isPending} + segments={item.segments} + /> +
+
+ ) +} diff --git a/apps/admin/src/features/ai/components/article-grouped/types.ts b/apps/admin/src/features/ai/components/article-grouped/types.ts index 67bd64aabb7..ee7ccc74daf 100644 --- a/apps/admin/src/features/ai/components/article-grouped/types.ts +++ b/apps/admin/src/features/ai/components/article-grouped/types.ts @@ -1,8 +1,9 @@ +import type { LucideIcon } from 'lucide-react' +import type { ComponentType } from 'react' + import type { ArticleInfo, PaginationInfo } from '~/api/ai' import type { TranslationKey } from '~/i18n/types' import type { HeaderAction } from '~/ui/layout/page-layout' -import type { LucideIcon } from 'lucide-react' -import type { ComponentType } from 'react' export interface ArticleGroup { article: ArticleInfo @@ -49,6 +50,8 @@ export interface ArticleGroupedConfig { inlineEmptyKey: TranslationKey itemDeleteConfirmKey: TranslationKey editTitleKey: TranslationKey + /** Label for the primary row-open action; defaults to `ai.action.edit`. */ + itemOpenLabelKey?: TranslationKey kindKey: TranslationKey groupedQueryKey: string @@ -69,7 +72,7 @@ export interface ArticleGroupedConfig { refId: string lang?: string }) => Promise<{ created: boolean; taskId: string }> - taskTypeForQueue: 'Summary' | 'Translation' | 'Insights' + taskTypeForQueue: 'Insights' | 'Summary' | 'Translation' | 'Tts' } pageActions?: (ctx: { invalidate: () => Promise }) => HeaderAction[] diff --git a/apps/admin/src/features/ai/components/article-grouped/useItemActions.ts b/apps/admin/src/features/ai/components/article-grouped/useItemActions.ts index 20230728cc6..d7477999f59 100644 --- a/apps/admin/src/features/ai/components/article-grouped/useItemActions.ts +++ b/apps/admin/src/features/ai/components/article-grouped/useItemActions.ts @@ -1,9 +1,10 @@ import { useMemo } from 'react' + +import { useI18n } from '~/i18n' import type { ListAction } from '~/ui/list-actions' import type { ContextMenuItem } from '~/ui/overlay/context-menu' -import type { ArticleGroupedConfig } from './types' -import { useI18n } from '~/i18n' +import type { ArticleGroupedConfig } from './types' interface UseItemActionsOptions { config: ArticleGroupedConfig @@ -22,12 +23,13 @@ export function useItemActions( ): UseItemActionsAPI { const { t } = useI18n() const { config, onEdit, onDelete, onExtraAction } = options + const openLabel = t(config.itemOpenLabelKey ?? 'ai.action.edit') const keyboardActions = useMemo>>( () => [ { key: 'edit', - label: t('ai.action.edit'), + label: openLabel, shortcut: 'Enter', run: (targets) => { const target = targets[0] @@ -45,14 +47,14 @@ export function useItemActions( }, }, ], - [t, onEdit, onDelete], + [t, openLabel, onEdit, onDelete], ) const buildMenu = (item: TItem): ContextMenuItem[] => { const base: ContextMenuItem[] = [ { key: 'edit', - label: t('ai.action.edit'), + label: openLabel, onClick: () => onEdit(item), }, { diff --git a/apps/admin/src/features/ai/hooks/use-ai-quick-actions.ts b/apps/admin/src/features/ai/hooks/use-ai-quick-actions.ts new file mode 100644 index 00000000000..1c87d3e526a --- /dev/null +++ b/apps/admin/src/features/ai/hooks/use-ai-quick-actions.ts @@ -0,0 +1,84 @@ +import { useMutation } from '@tanstack/react-query' +import { AudioLines, FileText, Languages, Sparkles } from 'lucide-react' +import { toast } from 'sonner' + +import { + createInsightsTask, + createSummaryTask, + createTranslationTask, + createTtsTask, +} from '~/api/ai' +import type { CreateTaskResponse } from '~/api/tasks' +import { useI18n } from '~/i18n' +import type { ContextMenuItem } from '~/ui/overlay/context-menu' + +import { presentGeneratePrompt } from '../components/article-grouped/GeneratePromptModal' +import { getErrorMessage } from '../utils/ai' + +export function useAiQuickActions(refId: string) { + const { t } = useI18n() + + const mutation = useMutation({ + mutationFn: async (fn: () => Promise) => fn(), + onError: (error: unknown) => + toast.error(getErrorMessage(error, t('ai.toast.taskCreateFailed'))), + onSuccess: (result) => { + toast.success( + result.created ? t('ai.toast.taskCreated') : t('ai.toast.taskExists'), + ) + }, + }) + + const runWithLangPrompt = async ( + title: string, + taskFn: (lang: string) => Promise, + ) => { + const result = await presentGeneratePrompt({ + langLabel: t('ai.translation.langLabel'), + promptForLang: true, + title, + }) + if (!result) return + const lang = result.lang?.trim().toLowerCase() ?? 'zh' + if (!lang) return + mutation.mutate(() => taskFn(lang)) + } + + const items: ContextMenuItem[] = [ + { + icon: FileText, + key: 'ai-summary', + label: t('ai.menu.generateSummary'), + onClick: () => + void runWithLangPrompt(t('ai.menu.generateSummary'), (lang) => + createSummaryTask({ refId, lang }), + ), + }, + { + icon: Sparkles, + key: 'ai-insights', + label: t('ai.menu.generateInsights'), + onClick: () => mutation.mutate(() => createInsightsTask({ refId })), + }, + { + icon: Languages, + key: 'ai-translation', + label: t('ai.menu.generateTranslation'), + onClick: () => + void runWithLangPrompt(t('ai.menu.generateTranslation'), (lang) => + createTranslationTask({ refId, targetLanguages: [lang] }), + ), + }, + { + icon: AudioLines, + key: 'ai-tts', + label: t('ai.menu.generateTts'), + onClick: () => + void runWithLangPrompt(t('ai.menu.generateTts'), (lang) => + createTtsTask({ refId, langs: [lang] }), + ), + }, + ] + + return items +} diff --git a/apps/admin/src/features/ai/routes/AiTtsRouteView.tsx b/apps/admin/src/features/ai/routes/AiTtsRouteView.tsx new file mode 100644 index 00000000000..c4cd6b046d9 --- /dev/null +++ b/apps/admin/src/features/ai/routes/AiTtsRouteView.tsx @@ -0,0 +1,85 @@ +import { Plus, RefreshCw } from 'lucide-react' +import { useMemo } from 'react' + +import type { AITtsRow } from '~/api/ai' +import { + createTtsTask, + deleteTts, + getTtsByRefId, + getTtsGrouped, +} from '~/api/ai' + +import { ArticleGroupedRouteView } from '../components/article-grouped/ArticleGroupedRouteView' +import { TtsPlaybackBody } from '../components/article-grouped/TtsPlaybackBody' +import type { ArticleGroupedConfig } from '../components/article-grouped/types' +import { buildTtsRegeneratePayload } from '../utils/ai' + +export function AiTtsRouteView() { + const config = useMemo>( + () => ({ + scopeIdPrefix: 'ai-tts', + pageTitleKey: 'routes.aiTts.title', + totalCountKey: 'ai.articleGrouped.totalCount', + itemCountKey: 'ai.articleGrouped.itemCount', + searchPlaceholderKey: 'ai.tts.searchPlaceholder', + emptyTitleKey: 'ai.articleGrouped.emptyTitle', + emptyDescriptionKey: 'ai.articleGrouped.emptyDescription', + detailEmptyTitleKey: 'ai.tts.emptyTitle', + detailEmptyDescriptionKey: 'ai.tts.emptyDescription', + detailSectionTitleKey: 'ai.tts.detailSectionTitle', + inlineEmptyKey: 'ai.articleGrouped.inlineEmpty', + itemDeleteConfirmKey: 'ai.articleGrouped.confirmDelete', + editTitleKey: 'ai.tts.playbackTitle', + itemOpenLabelKey: 'ai.tts.openLabel', + kindKey: 'ai.tts.kind', + + groupedQueryKey: 'tts', + getGroupedPage: async (params) => { + const response = await getTtsGrouped(params) + return { + data: response.data.map((group) => ({ + article: group.article, + items: group.narrations, + })), + pagination: response.pagination, + } + }, + getItemsByRef: async (refId) => { + const response = await getTtsByRefId(refId) + return { article: response.article, items: response.rows } + }, + deleteItem: deleteTts, + // Narrations are read-only; the playback body never submits, so this + // is never called. + updateItem: () => Promise.resolve(), + + generate: { + labelKey: 'ai.tts.generateLabel', + icon: Plus, + promptForLang: true, + runTask: ({ refId, lang }) => + createTtsTask({ refId, langs: lang ? [lang] : undefined }), + taskTypeForQueue: 'Tts', + }, + + extraItemActions: (item) => [ + { + id: 'regenerate', + labelKey: 'ai.action.regenerate', + icon: RefreshCw, + run: () => createTtsTask(buildTtsRegeneratePayload(item)), + }, + ], + + getPreview: (item) => item.segments[0]?.text ?? '', + getLang: (item) => item.lang, + getCreatedAt: (item) => item.updatedAt ?? '', + getId: (item) => item.id, + + EditDrawerBody: TtsPlaybackBody, + }), + [], + ) + + return config={config} /> +} diff --git a/apps/admin/src/features/ai/utils/ai.test.ts b/apps/admin/src/features/ai/utils/ai.test.ts new file mode 100644 index 00000000000..764090e07c0 --- /dev/null +++ b/apps/admin/src/features/ai/utils/ai.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' + +import { buildTtsRegeneratePayload } from './ai' + +describe('buildTtsRegeneratePayload', () => { + it('forces regeneration and scopes the task to the row language', () => { + expect(buildTtsRegeneratePayload({ lang: 'zh', refId: '42' })).toEqual({ + force: true, + langs: ['zh'], + refId: '42', + }) + }) + + it('always sets force, so an unchanged article still re-synthesizes', () => { + // Without this the management page's regenerate action is a no-op by + // construction: every row it can act on already has narration. + expect(buildTtsRegeneratePayload({ lang: 'en', refId: '7' }).force).toBe( + true, + ) + }) +}) diff --git a/apps/admin/src/features/ai/utils/ai.ts b/apps/admin/src/features/ai/utils/ai.ts index 4a80ad2ddaa..720d88ab624 100644 --- a/apps/admin/src/features/ai/utils/ai.ts +++ b/apps/admin/src/features/ai/utils/ai.ts @@ -66,36 +66,14 @@ export function editInsightsItem(item: AIInsights, t: Translator) { return updateInsights(item.id, { content }) } -export function getGroupedActionSuccessMessage(result: unknown, t: Translator) { - if (isCancelledActionResult(result)) return null - return getTaskMutationMessage(result, t) ?? t('ai.toast.saved') -} - -export function getTaskMutationMessage(result: unknown, t: Translator) { - if (isCancelledActionResult(result)) return null - if ( - result && - typeof result === 'object' && - 'taskId' in result && - 'created' in result - ) { - return (result as { created?: boolean }).created - ? t('ai.toast.taskCreated') - : t('ai.toast.taskExists') - } - - return null -} - -export function isCancelledActionResult(result: unknown): result is { - cancelled: true -} { - return ( - !!result && - typeof result === 'object' && - 'cancelled' in result && - (result as { cancelled?: unknown }).cancelled === true - ) +// Every row the TTS management page can act on already has narration, so +// without `force` planTts reuses each unchanged chunk and the enqueue is a +// no-op. +export function buildTtsRegeneratePayload(row: { + lang: string + refId: string +}) { + return { force: true, langs: [row.lang], refId: row.refId } } export function formatDateString(value?: string) { diff --git a/apps/admin/src/features/notes/components/NoteRow.tsx b/apps/admin/src/features/notes/components/NoteRow.tsx index 2bf3719cb7a..b66e6bbcf0b 100644 --- a/apps/admin/src/features/notes/components/NoteRow.tsx +++ b/apps/admin/src/features/notes/components/NoteRow.tsx @@ -5,6 +5,7 @@ import { ContentEntryListItem, ContentListStatusBadge, } from '~/features/_shared/components/content-list-item' +import { useAiQuickActions } from '~/features/ai/hooks/use-ai-quick-actions' import { useI18n } from '~/i18n' import type { NoteModel } from '~/models/note' import type { ListAction } from '~/ui/list-actions' @@ -26,6 +27,7 @@ export function NoteRow(props: { }) { const { t } = useI18n() const note = props.note + const aiMenuItems = useAiQuickActions(note.id) const isFuture = note.publicAt && +new Date(note.publicAt) - Date.now() > 0 const publicHref = `${WEB_URL}${buildNotePublicPath(note)}` const title = note.title || t('notes.row.untitled') @@ -34,6 +36,7 @@ export function NoteRow(props: { const menuItems = () => buildNoteMenuItems(note, { actions: props.actions, + aiMenuItems, externalHref: publicHref, onBookmarkToggle: (next) => props.onMetadataChange(note.id, { bookmark: next }), diff --git a/apps/admin/src/features/notes/components/buildNoteMenuItems.ts b/apps/admin/src/features/notes/components/buildNoteMenuItems.ts index 1b1a35154d7..3abe76d644f 100644 --- a/apps/admin/src/features/notes/components/buildNoteMenuItems.ts +++ b/apps/admin/src/features/notes/components/buildNoteMenuItems.ts @@ -1,5 +1,6 @@ import { CloudSun, Copy, Smile } from 'lucide-react' import { toast } from 'sonner' + import type { TranslationKey, TranslationValues } from '~/i18n/types' import type { NoteModel } from '~/models/note' import type { ListAction } from '~/ui/list-actions' @@ -11,6 +12,7 @@ type Translator = (key: TranslationKey, values?: TranslationValues) => string export interface BuildNoteMenuItemsOptions { actions: ReadonlyArray> + aiMenuItems?: ContextMenuItem[] externalHref: string onBookmarkToggle: (next: boolean) => void onMoodChange: (next: string | null) => void @@ -114,6 +116,21 @@ export function buildNoteMenuItems( options.onWeatherChange(next) }, }, + ) + + if (options.aiMenuItems?.length) { + items.push( + { key: 'sep-ai', type: 'divider' }, + { + children: options.aiMenuItems, + key: 'ai-submenu', + label: t('ai.menu.label'), + type: 'submenu', + }, + ) + } + + items.push( { key: 'sep-3', type: 'divider' }, { icon: Copy, diff --git a/apps/admin/src/features/posts/components/PostRow.tsx b/apps/admin/src/features/posts/components/PostRow.tsx index 7727a9e27bc..e192b4b848e 100644 --- a/apps/admin/src/features/posts/components/PostRow.tsx +++ b/apps/admin/src/features/posts/components/PostRow.tsx @@ -5,6 +5,7 @@ import { ContentEntryListItem, ContentListStatusBadge, } from '~/features/_shared/components/content-list-item' +import { useAiQuickActions } from '~/features/ai/hooks/use-ai-quick-actions' import { useI18n } from '~/i18n' import type { PostModel } from '~/models/post' import type { ListAction } from '~/ui/list-actions' @@ -32,6 +33,7 @@ export function PostRow(props: { }) { const { t } = useI18n() const post = props.post + const aiMenuItems = useAiQuickActions(post.id) const externalHref = `${WEB_URL}/posts/${post.category?.slug ?? post.categoryId}/${post.slug}` const isPublished = post.isPublished ?? false const title = post.title || t('posts.row.untitled') @@ -40,6 +42,7 @@ export function PostRow(props: { const menuItems = () => buildPostMenuItems(post, { actions: props.actions, + aiMenuItems, categories: props.categories, externalHref, onCategoryChange: (categoryId) => diff --git a/apps/admin/src/features/posts/components/buildPostMenuItems.ts b/apps/admin/src/features/posts/components/buildPostMenuItems.ts index 99f0bda0570..b8808c9cbd1 100644 --- a/apps/admin/src/features/posts/components/buildPostMenuItems.ts +++ b/apps/admin/src/features/posts/components/buildPostMenuItems.ts @@ -1,5 +1,6 @@ import { Check, Copy } from 'lucide-react' import { toast } from 'sonner' + import type { TranslationKey, TranslationValues } from '~/i18n/types' import type { PostModel } from '~/models/post' import type { ListAction } from '~/ui/list-actions' @@ -14,6 +15,7 @@ type Translator = (key: TranslationKey, values?: TranslationValues) => string export interface BuildPostMenuItemsOptions { actions: ReadonlyArray> + aiMenuItems?: ContextMenuItem[] categories: PostMenuCategoryOption[] externalHref: string onCategoryChange: (categoryId: string) => void @@ -103,6 +105,18 @@ export function buildPostMenuItems( }) } + if (options.aiMenuItems?.length) { + items.push( + { key: 'sep-ai', type: 'divider' }, + { + children: options.aiMenuItems, + key: 'ai-submenu', + label: t('ai.menu.label'), + type: 'submenu', + }, + ) + } + items.push( { key: 'sep-2', type: 'divider' }, { diff --git a/apps/admin/src/features/tasks/constants.ts b/apps/admin/src/features/tasks/constants.ts index 6e3176637ee..7f06aa8ea58 100644 --- a/apps/admin/src/features/tasks/constants.ts +++ b/apps/admin/src/features/tasks/constants.ts @@ -29,6 +29,7 @@ export const taskTypeLabelKeys: Record = { [AITaskType.Insights]: 'tasks.taskType.insights', [AITaskType.InsightsTranslation]: 'tasks.taskType.insightsTranslation', [AITaskType.ImageGeneration]: 'tasks.taskType.imageGeneration', + [AITaskType.Tts]: 'tasks.taskType.tts', } export const taskStatusLabelKeys: Record = { @@ -97,6 +98,7 @@ export const typeOptionKeys: Array<{ labelKey: 'tasks.taskType.imageGeneration', value: AITaskType.ImageGeneration, }, + { labelKey: 'tasks.taskType.tts', value: AITaskType.Tts }, ] export const scopeOptionKeys: Array<{ diff --git a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx index 0f2ea3ba9db..93913927da1 100644 --- a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx +++ b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx @@ -101,6 +101,7 @@ import { DraftConflictBanner } from '~/features/write/components/DraftConflictBa import { DraftHintBanner } from '~/features/write/components/DraftHintBanner' import { DraftPreviewBanner } from '~/features/write/components/DraftPreviewBanner' import { SkillPicker } from '~/features/write/components/SkillPicker' +import { TtsGenerationEntry } from '~/features/write/components/tts/TtsGenerationEntry' import { MetaPresetSection } from '~/features/write/meta-presets' import type { DraftMergeConflict } from '~/features/write/utils/merge-draft-conflict' import { mergeDraftConflict } from '~/features/write/utils/merge-draft-conflict' @@ -3368,6 +3369,7 @@ function MediaAndMetaFields(props: { text={props.state.text} title={props.state.title} /> + {images.length > 0 ? (
diff --git a/apps/admin/src/features/write/components/tts/TtsGenerationDrawer.tsx b/apps/admin/src/features/write/components/tts/TtsGenerationDrawer.tsx new file mode 100644 index 00000000000..6cc153c543f --- /dev/null +++ b/apps/admin/src/features/write/components/tts/TtsGenerationDrawer.tsx @@ -0,0 +1,193 @@ +import { AudioLines, Loader2, RefreshCw, Sparkles } from 'lucide-react' + +import { TtsSegmentPlayer } from '~/features/_shared/components/tts/TtsSegmentPlayer' +import { useI18n } from '~/i18n' +import { Drawer } from '~/ui/feedback/drawer' +import { EmptyState } from '~/ui/patterns/EmptyState' +import { Button } from '~/ui/primitives/button' +import { Scroll } from '~/ui/primitives/scroll' +import { cn } from '~/utils/cn' + +import type { useTtsGeneration } from './use-tts-generation' + +type TtsGenerationDrawerProps = ReturnType + +export function TtsGenerationDrawer(props: TtsGenerationDrawerProps) { + const { t } = useI18n() + + return ( + + + {props.rows.length > 0 ? ( +
+ {props.rows.map((row) => { + const active = row.lang === props.activeLang + return ( + + ) + })} +
+ ) : null} + + {props.isLoading ? ( +

+ {t('write.ttsGeneration.status.loading')} +

+ ) : props.activeRow ? ( +
+
+ + + + +
+

+ {t('write.ttsGeneration.charCount', { + count: props.activeRow.charCount, + })} +

+ +
+ ) : ( + + )} + + {props.runStatus !== 'idle' ? ( + + ) : null} + +
+ + +
+

+ {t('write.ttsGeneration.regenerateHint')} +

+
+
+ ) +} + +function ConfigItem(props: { label: string; value: string }) { + return ( +
+
{props.label}
+
{props.value}
+
+ ) +} + +function RunStatusPanel(props: { + progress: null | number + progressMessage?: string + runError?: string + runStatus: 'failed' | 'running' | 'succeeded' +}) { + const { t } = useI18n() + const percent = Math.max(0, Math.min(100, props.progress ?? 0)) + + return ( +
+
+ + {props.runStatus === 'running' + ? t('write.ttsGeneration.status.generating') + : props.runStatus === 'succeeded' + ? t('write.ttsGeneration.status.upToDate') + : t('write.ttsGeneration.status.failed')} + + {props.runStatus === 'running' && props.progress !== null ? ( + {Math.round(percent)}% + ) : null} +
+ {props.runStatus === 'running' ? ( +
+
+
+ ) : null} + {props.runStatus === 'running' && props.progressMessage ? ( +

{props.progressMessage}

+ ) : null} + {props.runStatus === 'failed' && props.runError ? ( +

{props.runError}

+ ) : null} +
+ ) +} diff --git a/apps/admin/src/features/write/components/tts/TtsGenerationEntry.tsx b/apps/admin/src/features/write/components/tts/TtsGenerationEntry.tsx new file mode 100644 index 00000000000..d68cd633a89 --- /dev/null +++ b/apps/admin/src/features/write/components/tts/TtsGenerationEntry.tsx @@ -0,0 +1,40 @@ +import { useQuery } from '@tanstack/react-query' +import { AudioLines } from 'lucide-react' + +import { getOption } from '~/api/options' +import { useI18n } from '~/i18n' +import { adminQueryKeys } from '~/query/keys' +import { Button } from '~/ui/primitives/button' + +import { TtsGenerationDrawer } from './TtsGenerationDrawer' +import { useTtsGeneration } from './use-tts-generation' + +export function TtsGenerationEntry(props: { refId?: string }) { + const { t } = useI18n() + + const optionsQuery = useQuery({ + queryFn: () => getOption<{ enable?: boolean }>('ttsOptions'), + queryKey: adminQueryKeys.ai.ttsOptions(), + staleTime: 60_000, + }) + const enabled = Boolean(optionsQuery.data?.enable) + + const generation = useTtsGeneration({ enabled, refId: props.refId }) + + if (!enabled || !props.refId) return null + + return ( + <> + + + + ) +} diff --git a/apps/admin/src/features/write/components/tts/use-tts-generation.test.tsx b/apps/admin/src/features/write/components/tts/use-tts-generation.test.tsx new file mode 100644 index 00000000000..883fb930625 --- /dev/null +++ b/apps/admin/src/features/write/components/tts/use-tts-generation.test.tsx @@ -0,0 +1,150 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, createElement } from 'react' +import type { Root } from 'react-dom/client' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { I18nProvider } from '~/i18n' + +import { useTtsGeneration } from './use-tts-generation' + +const { createTtsTaskMock, getTtsByRefIdMock, getTaskMock, toastErrorMock } = + vi.hoisted(() => ({ + createTtsTaskMock: vi.fn(), + getTtsByRefIdMock: vi.fn(), + getTaskMock: vi.fn(), + toastErrorMock: vi.fn(), + })) + +vi.mock('~/api/ai', () => ({ + createTtsTask: createTtsTaskMock, + getTtsByRefId: getTtsByRefIdMock, +})) + +vi.mock('~/api/tasks', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, getTask: getTaskMock } +}) + +vi.mock('~/features/tasks/hooks/useTaskSubscription', () => ({ + useTaskDetailSubscription: () => ({ socketConnected: false }), +})) + +vi.mock('sonner', () => ({ + toast: { error: toastErrorMock, success: vi.fn(), warning: vi.fn() }, +})) + +interface Harness { + root: Root + unmount: () => void +} + +function mount(): Harness { + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + return { + root, + unmount: () => { + act(() => { + root.unmount() + }) + container.remove() + }, + } +} + +let latest: ReturnType | undefined + +function Probe(props: Parameters[0]) { + latest = useTtsGeneration(props) + return null +} + +async function flush(times = 3) { + for (let i = 0; i < times; i += 1) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + } +} + +let harness: Harness +let client: QueryClient + +beforeEach(() => { + harness = mount() + client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }) + latest = undefined + createTtsTaskMock.mockReset().mockResolvedValue({ taskId: 'task-1' }) + getTtsByRefIdMock.mockReset().mockResolvedValue({ article: null, rows: [] }) + getTaskMock.mockReset() + toastErrorMock.mockReset() + + act(() => { + harness.root.render( + createElement( + QueryClientProvider, + { client }, + createElement( + I18nProvider, + null, + createElement(Probe, { enabled: true, refId: 'article-1' }), + ), + ), + ) + }) +}) + +afterEach(() => { + harness.unmount() + document.body.innerHTML = '' +}) + +describe('useTtsGeneration task polling', () => { + it('leaves the panel runnable after the task poll fails', async () => { + getTaskMock.mockRejectedValue(new Error('task not found')) + await flush() + + act(() => { + latest!.generate() + }) + await flush() + await flush() + + expect(latest!.isRunning).toBe(false) + expect(latest!.runStatus).toBe('failed') + expect(latest!.runError).toBe('task not found') + expect(toastErrorMock).toHaveBeenCalledWith('task not found') + }) + + it('keeps running while the task is still pending', async () => { + getTaskMock.mockResolvedValue({ id: 'task-1', status: 'running' }) + await flush() + + act(() => { + latest!.generate() + }) + await flush() + await flush() + + expect(latest!.isRunning).toBe(true) + expect(latest!.runStatus).toBe('running') + }) + + it('clears the run once the task completes', async () => { + getTaskMock.mockResolvedValue({ id: 'task-1', status: 'completed' }) + await flush() + + act(() => { + latest!.generate() + }) + await flush(10) + + expect(latest!.isRunning).toBe(false) + expect(latest!.runStatus).toBe('succeeded') + expect(toastErrorMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/admin/src/features/write/components/tts/use-tts-generation.ts b/apps/admin/src/features/write/components/tts/use-tts-generation.ts new file mode 100644 index 00000000000..180c6d528ff --- /dev/null +++ b/apps/admin/src/features/write/components/tts/use-tts-generation.ts @@ -0,0 +1,160 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { createTtsTask, getTtsByRefId } from '~/api/ai' +import { AITaskStatus, getTask } from '~/api/tasks' +import { + fallbackPollingIntervalMs, + liveSubscribeIntervalMs, +} from '~/features/tasks/constants' +import { useTaskDetailSubscription } from '~/features/tasks/hooks/useTaskSubscription' +import { getProgress } from '~/features/tasks/utils/tasks' +import { useI18n } from '~/i18n' +import { adminQueryKeys } from '~/query/keys' + +type TtsRunStatus = 'failed' | 'idle' | 'running' | 'succeeded' + +interface UseTtsGenerationParams { + enabled: boolean + refId?: string +} + +export function useTtsGeneration(params: UseTtsGenerationParams) { + const { t } = useI18n() + const queryClient = useQueryClient() + const [open, setOpen] = useState(false) + const [activeLang, setActiveLang] = useState(null) + const [pendingTaskId, setPendingTaskId] = useState(null) + const [runStatus, setRunStatus] = useState('idle') + const [runError, setRunError] = useState() + + const refId = params.refId + const canFetch = params.enabled && Boolean(refId) + + // The write route keeps refId in useSearchParams rather than a route param + // that would force a remount, so switching articles reuses this hook + // instance — reset every run-scoped state or a stale banner/task would be + // attributed to the newly selected article. + useEffect(() => { + setOpen(false) + setActiveLang(null) + setPendingTaskId(null) + setRunStatus('idle') + setRunError(undefined) + }, [refId]) + + const rowsQuery = useQuery({ + enabled: canFetch, + queryFn: () => getTtsByRefId(refId!), + queryKey: adminQueryKeys.ai.ttsByRef(refId ?? ''), + }) + const rows = rowsQuery.data?.rows ?? [] + + useEffect(() => { + if (activeLang && rows.some((row) => row.lang === activeLang)) return + setActiveLang(rows[0]?.lang ?? null) + }, [rows, activeLang]) + + const activeRow = rows.find((row) => row.lang === activeLang) ?? null + + const createTaskMutation = useMutation({ + mutationFn: createTtsTask, + onError: (error) => { + const message = getErrorMessage( + error, + t('write.ttsGeneration.toast.generateFailed'), + ) + setRunStatus('failed') + setRunError(message) + toast.error(message) + }, + onSuccess: (result) => setPendingTaskId(result.taskId), + }) + + const { socketConnected } = useTaskDetailSubscription(pendingTaskId) + const taskQuery = useQuery({ + enabled: Boolean(pendingTaskId), + queryFn: () => getTask(pendingTaskId!), + queryKey: adminQueryKeys.tasks.taskDetail(pendingTaskId ?? ''), + refetchInterval: () => + socketConnected ? liveSubscribeIntervalMs : fallbackPollingIntervalMs, + refetchIntervalInBackground: true, + }) + + // Without this the poll dying (task pruned from the queue, network drop) would + // leave pendingTaskId set forever, and with it a permanently disabled panel. + useEffect(() => { + if (!pendingTaskId || !taskQuery.isError) return + const message = getErrorMessage( + taskQuery.error, + t('write.ttsGeneration.toast.generateFailed'), + ) + setRunStatus('failed') + setRunError(message) + toast.error(message) + setPendingTaskId(null) + }, [taskQuery.isError, taskQuery.error, pendingTaskId, t]) + + useEffect(() => { + const task = taskQuery.data + if (!task || !pendingTaskId || task.id !== pendingTaskId) return + if ( + task.status === AITaskStatus.Pending || + task.status === AITaskStatus.Running + ) { + return + } + + if (task.status === AITaskStatus.Completed) { + setRunStatus('succeeded') + } else { + const message = + task.error ?? t('write.ttsGeneration.toast.generateFailed') + setRunStatus('failed') + setRunError(message) + toast.error(message) + } + + void queryClient.invalidateQueries({ + queryKey: adminQueryKeys.ai.ttsByRef(refId ?? ''), + }) + setPendingTaskId(null) + }, [taskQuery.data, pendingTaskId, refId, queryClient, t]) + + const activeTask = + taskQuery.data && pendingTaskId && taskQuery.data.id === pendingTaskId + ? taskQuery.data + : null + const isRunning = createTaskMutation.isPending || Boolean(pendingTaskId) + + const runTask = (force: boolean) => { + if (!refId || isRunning) return + setRunStatus('running') + setRunError(undefined) + createTaskMutation.mutate({ force, refId }) + } + + return { + activeLang, + activeRow, + closeDrawer: () => setOpen(false), + generate: () => runTask(false), + isLoading: rowsQuery.isLoading, + isRunning, + open, + openDrawer: () => setOpen(true), + progress: activeTask ? getProgress(activeTask) : null, + progressMessage: activeTask?.progressMessage, + regenerate: () => runTask(true), + rows, + runError, + runStatus, + setActiveLang, + } +} + +function getErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error && error.message) return error.message + return fallback +} diff --git a/apps/admin/src/i18n/resources/en-US.ts b/apps/admin/src/i18n/resources/en-US.ts index 5ccef4cc590..3cce62b0c9f 100644 --- a/apps/admin/src/i18n/resources/en-US.ts +++ b/apps/admin/src/i18n/resources/en-US.ts @@ -4,6 +4,11 @@ export const enUS = { 'tasks.action.cancel': 'Cancel task', 'tasks.action.clearCompleted': 'Clear completed', 'ai.action.create': 'Create task', + 'ai.menu.label': 'AI', + 'ai.menu.generateSummary': 'Generate summary', + 'ai.menu.generateInsights': 'Generate insights', + 'ai.menu.generateTranslation': 'Generate translation', + 'ai.menu.generateTts': 'Generate narration', 'ai.action.delete': 'Delete', 'tasks.action.deleteTask': 'Delete task', 'ai.action.edit': 'Edit', @@ -102,6 +107,7 @@ export const enUS = { 'ai.surface.summaries': 'Summaries', 'ai.surface.translations': 'Translations', 'ai.tab.entries': 'Entries', + 'ai.tab.tts': 'Narrations', 'tasks.task.batch': 'Batch', 'tasks.task.completedAt': 'Completed at', 'tasks.task.completedItems': 'Completed', @@ -153,6 +159,7 @@ export const enUS = { 'tasks.taskType.translation': 'Translation', 'tasks.taskType.translationAll': 'Translate all', 'tasks.taskType.translationBatch': 'Batch translation', + 'tasks.taskType.tts': 'Text-to-speech generation', 'tasks.countSuffix': '{count} total', 'tasks.filter.allScope': 'All', 'tasks.filter.allStatus': 'All statuses', @@ -233,6 +240,17 @@ export const enUS = { 'ai.translation.listSectionTitle': 'Translation list', 'ai.translation.searchPlaceholder': 'Search by article title', 'ai.translation.title': 'Translation entries', + 'ai.tts.charCountLabel': 'Characters', + 'ai.tts.detailSectionTitle': 'Narration detail', + 'ai.tts.emptyDescription': + 'Select an article from the list to view its AI narration', + 'ai.tts.emptyTitle': 'Select an article', + 'ai.tts.generateLabel': 'Generate narration', + 'ai.tts.kind': 'narration', + 'ai.tts.openLabel': 'Play', + 'ai.tts.playbackTitle': 'Play narration', + 'ai.tts.searchPlaceholder': 'Search by article title', + 'ai.tts.updatedAtLabel': 'Updated', 'ai.writer.fieldSlug': 'Slug', 'ai.writer.fieldTitle': 'Title', 'ai.writer.placeholder.text': 'Article content', @@ -1687,6 +1705,9 @@ export const enUS = { 'routes.aiTranslationEntries.description': 'AI translation glossary and terminology maintenance.', 'routes.aiTranslationEntries.title': 'Glossary', + 'routes.aiTts.description': + 'AI narrations grouped by article — listen, regenerate, and delete.', + 'routes.aiTts.title': 'Narrations', 'routes.analyze.description': 'Traffic metrics, paths, IP records, and visitor analysis.', 'routes.analyze.title': 'Analyze', @@ -3040,6 +3061,13 @@ export const enUS = { 'topics.notes.removeFromTopic': 'Remove from topic', 'topics.notes.title': 'Included notes', 'topics.notes.unnamed': 'Untitled note', + 'ttsPlayer.empty': 'No audio segments yet', + 'ttsPlayer.pauseSegment': 'Pause this segment', + 'ttsPlayer.playAll': 'Play from start', + 'ttsPlayer.playSegment': 'Play this segment', + 'ttsPlayer.progress': 'Segment {current}/{total}', + 'ttsPlayer.segmentsTitle': 'Segments', + 'ttsPlayer.stop': 'Stop', 'ui.bottomSheet.closeAria': 'Close', 'ui.bottomSheet.collapse': 'Collapse', @@ -3509,6 +3537,27 @@ export const enUS = { 'Drafting a prompt needs a text AI provider — a separate setup from the image generation provider above — and it is not configured yet.', 'write.coverGeneration.writerProviderMissingLink': 'Configure in Settings → AI', + 'write.ttsGeneration.blockCount': '{count} blocks', + 'write.ttsGeneration.blockCountLabel': 'Blocks', + 'write.ttsGeneration.charCount': '{count} characters', + 'write.ttsGeneration.empty.description': + 'Generate AI narration for this article to preview it here.', + 'write.ttsGeneration.empty.title': 'No narration yet', + 'write.ttsGeneration.entry': 'AI Narration', + 'write.ttsGeneration.generate': 'Generate', + 'write.ttsGeneration.modelLabel': 'Model', + 'write.ttsGeneration.regenerate': 'Regenerate', + 'write.ttsGeneration.regenerateHint': + 'Regenerate re-synthesizes every block using the current global voice config, discarding the locked config this narration was generated with.', + 'write.ttsGeneration.speedLabel': 'Speed', + 'write.ttsGeneration.status.failed': 'Narration generation failed', + 'write.ttsGeneration.status.generating': 'Generating narration…', + 'write.ttsGeneration.status.loading': 'Loading narration…', + 'write.ttsGeneration.status.upToDate': 'Up to date', + 'write.ttsGeneration.title': 'AI Narration', + 'write.ttsGeneration.toast.generateFailed': + 'Failed to start narration generation', + 'write.ttsGeneration.voiceLabel': 'Voice', 'write.section.lexicalDebug.copyButton': 'Copy', 'write.section.lexicalDebug.copyOk': 'Lexical State copied', 'write.section.lexicalDebug.footer': diff --git a/apps/admin/src/i18n/resources/zh-CN.ts b/apps/admin/src/i18n/resources/zh-CN.ts index 4c0ff48cbd7..3cd879a47e8 100644 --- a/apps/admin/src/i18n/resources/zh-CN.ts +++ b/apps/admin/src/i18n/resources/zh-CN.ts @@ -1,6 +1,11 @@ export const zhCN = { 'tasks.action.cancel': '取消任务', 'tasks.action.clearCompleted': '清理已完成', + 'ai.menu.label': 'AI', + 'ai.menu.generateSummary': '生成摘要', + 'ai.menu.generateInsights': '生成精读', + 'ai.menu.generateTranslation': '生成翻译', + 'ai.menu.generateTts': '生成朗读', 'ai.action.create': '创建任务', 'ai.action.delete': '删除', 'tasks.action.deleteTask': '删除任务', @@ -96,6 +101,7 @@ export const zhCN = { 'ai.surface.summaries': '摘要', 'ai.surface.translations': '翻译', 'ai.tab.entries': '词表', + 'ai.tab.tts': '朗读', 'tasks.task.batch': '批量', 'tasks.task.completedAt': '完成时间', 'tasks.task.completedItems': '已完成', @@ -146,6 +152,7 @@ export const zhCN = { 'tasks.taskType.translation': '翻译', 'tasks.taskType.translationAll': '全量翻译', 'tasks.taskType.translationBatch': '批量翻译', + 'tasks.taskType.tts': '语音生成', 'tasks.countSuffix': '{count} 个', 'tasks.filter.allScope': '全部', 'tasks.filter.allStatus': '全部状态', @@ -225,6 +232,16 @@ export const zhCN = { 'ai.translation.listSectionTitle': '翻译列表', 'ai.translation.searchPlaceholder': '输入文章标题关键词', 'ai.translation.title': '翻译词表', + 'ai.tts.charCountLabel': '字符数', + 'ai.tts.detailSectionTitle': '朗读详情', + 'ai.tts.emptyDescription': '从左侧列表选择文章查看 AI 朗读', + 'ai.tts.emptyTitle': '选择一篇文章', + 'ai.tts.generateLabel': '生成朗读', + 'ai.tts.kind': '朗读', + 'ai.tts.openLabel': '播放', + 'ai.tts.playbackTitle': '播放朗读', + 'ai.tts.searchPlaceholder': '输入文章标题关键词', + 'ai.tts.updatedAtLabel': '更新时间', 'ai.writer.fieldSlug': 'Slug', 'ai.writer.fieldTitle': '标题', 'ai.writer.placeholder.text': '文章内容', @@ -1643,6 +1660,9 @@ export const zhCN = { 'routes.aiTranslation.title': '翻译', 'routes.aiTranslationEntries.description': 'AI 翻译词表与术语维护。', 'routes.aiTranslationEntries.title': '翻译词表', + 'routes.aiTts.description': + '按文章分组管理 AI 朗读 —— 试听、重新生成与删除。', + 'routes.aiTts.title': '朗读', 'routes.analyze.description': '流量指标、访问路径、IP 记录与访客分析。', 'routes.analyze.title': '分析', 'routes.backups.description': '数据库备份归档与恢复操作。', @@ -2892,6 +2912,13 @@ export const zhCN = { 'topics.notes.removeFromTopic': '移出专栏', 'topics.notes.title': '包含的手记', 'topics.notes.unnamed': '未命名手记', + 'ttsPlayer.empty': '暂无音频片段', + 'ttsPlayer.pauseSegment': '暂停该段', + 'ttsPlayer.playAll': '从头播放', + 'ttsPlayer.playSegment': '播放该段', + 'ttsPlayer.progress': '第 {current}/{total} 段', + 'ttsPlayer.segmentsTitle': '片段', + 'ttsPlayer.stop': '停止', 'ui.bottomSheet.closeAria': '关闭', 'ui.bottomSheet.collapse': '收起', @@ -3342,6 +3369,26 @@ export const zhCN = { 'write.coverGeneration.writerProviderMissingHint': '生成提示词需要文本 AI provider——与上方的生图 provider 是两套独立配置,目前尚未配置。', 'write.coverGeneration.writerProviderMissingLink': '前往「设置 → AI」配置', + 'write.ttsGeneration.blockCount': '{count} 个块', + 'write.ttsGeneration.blockCountLabel': '块数', + 'write.ttsGeneration.charCount': '{count} 个字符', + 'write.ttsGeneration.empty.description': + '为这篇文章生成 AI 朗读后即可在此预览。', + 'write.ttsGeneration.empty.title': '暂无朗读', + 'write.ttsGeneration.entry': 'AI 朗读', + 'write.ttsGeneration.generate': '生成', + 'write.ttsGeneration.modelLabel': '模型', + 'write.ttsGeneration.regenerate': '重新生成', + 'write.ttsGeneration.regenerateHint': + '重新生成会用当前全局语音配置重新合成每个块,并丢弃该朗读生成时锁定的配置。', + 'write.ttsGeneration.speedLabel': '语速', + 'write.ttsGeneration.status.failed': '朗读生成失败', + 'write.ttsGeneration.status.generating': '正在生成朗读…', + 'write.ttsGeneration.status.loading': '朗读加载中…', + 'write.ttsGeneration.status.upToDate': '已是最新', + 'write.ttsGeneration.title': 'AI 朗读', + 'write.ttsGeneration.toast.generateFailed': '朗读生成任务启动失败', + 'write.ttsGeneration.voiceLabel': '音色', 'write.section.lexicalDebug.copyButton': '复制', 'write.section.lexicalDebug.copyOk': 'Lexical State 已复制', 'write.section.lexicalDebug.footer': '只读查看当前页面富文本序列化状态。', diff --git a/apps/admin/src/query/keys.ts b/apps/admin/src/query/keys.ts index 4275ec74bf9..325d733fa97 100644 --- a/apps/admin/src/query/keys.ts +++ b/apps/admin/src/query/keys.ts @@ -37,6 +37,9 @@ export const adminQueryKeys = { page: number size: number }) => ['ai', 'translation-entries', params] as const, + ttsByRef: (refId: string) => ['ai', 'tts', 'by-ref', refId] as const, + ttsOptions: () => ['ai', 'tts', 'options'] as const, + ttsRoot: ['ai', 'tts'] as const, }, analyze: { activity: (params: { page: number; size: number; type: number }) => diff --git a/apps/admin/src/views/(intelligence)/ai/tts/[id]/page.tsx b/apps/admin/src/views/(intelligence)/ai/tts/[id]/page.tsx new file mode 100644 index 00000000000..44df2ea304a --- /dev/null +++ b/apps/admin/src/views/(intelligence)/ai/tts/[id]/page.tsx @@ -0,0 +1,7 @@ +import { defineMetadata } from '~/lib/route-meta' + +export const metadata = defineMetadata({ + hidden: true, +}) + +export { ArticleGroupedDetailRoute as default } from '~/features/ai/components/article-grouped/ArticleGroupedDetailRoute' diff --git a/apps/admin/src/views/(intelligence)/ai/tts/page.tsx b/apps/admin/src/views/(intelligence)/ai/tts/page.tsx new file mode 100644 index 00000000000..ba9de4ab730 --- /dev/null +++ b/apps/admin/src/views/(intelligence)/ai/tts/page.tsx @@ -0,0 +1,12 @@ +import { AudioLines } from 'lucide-react' + +import { defineMetadata } from '~/lib/route-meta' + +export const metadata = defineMetadata({ + titleKey: 'routes.aiTts.title', + descriptionKey: 'routes.aiTts.description', + icon: AudioLines, + order: 5, +}) + +export { AiTtsRouteView as default } from '~/features/ai/routes/AiTtsRouteView' diff --git a/apps/core/src/common/errors/app-error-code.ts b/apps/core/src/common/errors/app-error-code.ts index b8352124302..d07ed371057 100644 --- a/apps/core/src/common/errors/app-error-code.ts +++ b/apps/core/src/common/errors/app-error-code.ts @@ -53,6 +53,13 @@ export enum AppErrorCode { IMAGE_PROVIDER_NOT_CONFIGURED = 'IMAGE_PROVIDER_NOT_CONFIGURED', IMAGE_GENERATION_FAILED = 'IMAGE_GENERATION_FAILED', + // TTS + TTS_DISABLED = 'TTS_DISABLED', + TTS_PROVIDER_NOT_CONFIGURED = 'TTS_PROVIDER_NOT_CONFIGURED', + TTS_SOURCE_NOT_LEXICAL = 'TTS_SOURCE_NOT_LEXICAL', + TTS_GENERATION_FAILED = 'TTS_GENERATION_FAILED', + TTS_BUDGET_EXCEEDED = 'TTS_BUDGET_EXCEEDED', + // auth AUTH_DEVICE_FLOW_PENDING = 'AUTH_DEVICE_FLOW_PENDING', AUTH_INVALID_CREDENTIALS = 'AUTH_INVALID_CREDENTIALS', diff --git a/apps/core/src/common/errors/app-error-definitions.ts b/apps/core/src/common/errors/app-error-definitions.ts index a1c515304fe..29dfd9f9b3a 100644 --- a/apps/core/src/common/errors/app-error-definitions.ts +++ b/apps/core/src/common/errors/app-error-definitions.ts @@ -222,6 +222,31 @@ export const APP_ERROR_DEFINITIONS = { message: (p) => p?.message ?? 'Image generation failed', }, + // TTS + [AppErrorCode.TTS_DISABLED]: { + status: 403, + message: 'AI narration is disabled', + }, + [AppErrorCode.TTS_PROVIDER_NOT_CONFIGURED]: { + status: 400, + message: 'TTS provider is not configured', + }, + [AppErrorCode.TTS_SOURCE_NOT_LEXICAL]: { + status: 400, + message: 'No requested language has narratable Lexical content', + details: (p) => (p?.lang ? { lang: p.lang } : undefined), + }, + [AppErrorCode.TTS_GENERATION_FAILED]: { + status: 500, + message: (p) => p?.message ?? 'Speech generation failed', + }, + [AppErrorCode.TTS_BUDGET_EXCEEDED]: { + status: 400, + message: (p) => + `Planned narration of ${p.charCount} characters exceeds the ${p.limit} limit`, + details: (p) => ({ charCount: p.charCount, limit: p.limit }), + }, + // auth [AppErrorCode.AUTH_DEVICE_FLOW_PENDING]: { status: 202, diff --git a/apps/core/src/common/errors/app-error-payload.ts b/apps/core/src/common/errors/app-error-payload.ts index e45bca35e47..6a377bb537f 100644 --- a/apps/core/src/common/errors/app-error-payload.ts +++ b/apps/core/src/common/errors/app-error-payload.ts @@ -59,6 +59,13 @@ export type AppErrorPayloadMap = { [AppErrorCode.IMAGE_PROVIDER_NOT_CONFIGURED]: undefined [AppErrorCode.IMAGE_GENERATION_FAILED]: OptMessage + // TTS + [AppErrorCode.TTS_DISABLED]: undefined + [AppErrorCode.TTS_PROVIDER_NOT_CONFIGURED]: undefined + [AppErrorCode.TTS_SOURCE_NOT_LEXICAL]: { lang?: string } | undefined + [AppErrorCode.TTS_GENERATION_FAILED]: { message?: string } | undefined + [AppErrorCode.TTS_BUDGET_EXCEEDED]: { charCount: number; limit: number } + // auth [AppErrorCode.AUTH_DEVICE_FLOW_PENDING]: undefined [AppErrorCode.AUTH_INVALID_CREDENTIALS]: undefined diff --git a/apps/core/src/common/response/meta.types.ts b/apps/core/src/common/response/meta.types.ts index f93a9c9248e..235c2dbdfae 100644 --- a/apps/core/src/common/response/meta.types.ts +++ b/apps/core/src/common/response/meta.types.ts @@ -105,6 +105,16 @@ export const InsightsMetaSchema = z .object({ hasInLocale: z.boolean() }) .strict() +export const TtsMetaSchema = z + .object({ + available: z.boolean(), + lang: z.string().optional(), + blockCount: z.number().optional(), + stale: z.boolean().optional(), + updatedAt: z.date().nullish(), + }) + .strict() + export const SummaryMetaSchema = z .object({ id: z.string(), @@ -143,11 +153,13 @@ export const PostResponseMetaSchema = BaseResponseMetaSchema.extend({ summary: SummaryMetaSchema.optional(), skills: z.array(SkillBundleViewSchema).optional(), paywall: PaywallMetaSchema.optional(), + tts: TtsMetaSchema.optional(), }) export const NoteResponseMetaSchema = BaseResponseMetaSchema.extend({ insights: InsightsMetaSchema.optional(), summary: SummaryMetaSchema.optional(), + tts: TtsMetaSchema.optional(), }) /** @@ -166,6 +178,7 @@ export type EnrichmentEntry = z.infer export type RelatedRef = z.infer export type ArticleRefMap = Record export type InsightsMeta = z.infer +export type TtsMeta = z.infer export type SummaryMeta = z.infer export type PaywallMeta = z.infer export type BaseResponseMeta = z.infer diff --git a/apps/core/src/database/migrations/0028_ai_tts.sql b/apps/core/src/database/migrations/0028_ai_tts.sql new file mode 100644 index 00000000000..a9ee066009c --- /dev/null +++ b/apps/core/src/database/migrations/0028_ai_tts.sql @@ -0,0 +1,43 @@ +-- migration-lint:allow=no-bare-create-index reason=indexes target brand-new empty ai_tts tables; CONCURRENTLY cannot run inside the migration transaction +CREATE TABLE "ai_tts" ( + "id" text PRIMARY KEY NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone, + "ref_id" text NOT NULL, + "lang" text NOT NULL, + "is_translation" boolean DEFAULT false NOT NULL, + "source_lang" text, + "model" text NOT NULL, + "voice" text NOT NULL, + "speed" real DEFAULT 1 NOT NULL, + "format" text DEFAULT 'mp3' NOT NULL, + "block_order" jsonb DEFAULT '[]'::jsonb NOT NULL, + "char_count" integer DEFAULT 0 NOT NULL, + "total_duration_ms" integer, + "source_modified_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "ai_tts_blocks" ( + "id" text PRIMARY KEY NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "tts_id" text NOT NULL, + "block_id" text NOT NULL, + "fingerprint" text NOT NULL, + "chunk_index" integer DEFAULT 0 NOT NULL, + "text" text NOT NULL, + "url" text NOT NULL, + "storage_backend" text NOT NULL, + "storage_key" text NOT NULL, + "byte_size" integer, + "duration_ms" integer +); +--> statement-breakpoint +ALTER TABLE "ai_tts_blocks" ADD CONSTRAINT "ai_tts_blocks_tts_id_ai_tts_id_fk" FOREIGN KEY ("tts_id") REFERENCES "public"."ai_tts"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE UNIQUE INDEX "ai_tts_ref_lang_uniq" ON "ai_tts" USING btree ("ref_id","lang"); +--> statement-breakpoint +CREATE INDEX "ai_tts_ref_id_idx" ON "ai_tts" USING btree ("ref_id"); +--> statement-breakpoint +CREATE UNIQUE INDEX "ai_tts_blocks_key_uniq" ON "ai_tts_blocks" USING btree ("tts_id","block_id","chunk_index"); +--> statement-breakpoint +CREATE INDEX "ai_tts_blocks_tts_id_idx" ON "ai_tts_blocks" USING btree ("tts_id"); diff --git a/apps/core/src/database/migrations/meta/0028_snapshot.json b/apps/core/src/database/migrations/meta/0028_snapshot.json new file mode 100644 index 00000000000..b188e075662 --- /dev/null +++ b/apps/core/src/database/migrations/meta/0028_snapshot.json @@ -0,0 +1,6571 @@ +{ + "id": "a0994180-6193-4e61-a19e-72c9d8c62504", + "prevId": "c368d75c-672d-40db-83cd-3d72f2b600d2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_agent_conversations": { + "name": "ai_agent_conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ai_agent_conversation_session_idx": { + "name": "ai_agent_conversation_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_insights": { + "name": "ai_insights", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_translation": { + "name": "is_translation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_insights_id": { + "name": "source_insights_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_lang": { + "name": "source_lang", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_info": { + "name": "model_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ai_insights_ref_lang_uniq": { + "name": "ai_insights_ref_lang_uniq", + "columns": [ + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_insights_source_insights_id_ai_insights_id_fk": { + "name": "ai_insights_source_insights_id_ai_insights_id_fk", + "tableFrom": "ai_insights", + "tableTo": "ai_insights", + "columnsFrom": [ + "source_insights_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_summaries": { + "name": "ai_summaries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ai_summaries_ref_id_idx": { + "name": "ai_summaries_ref_id_idx", + "columns": [ + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_translations": { + "name": "ai_translations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_lang": { + "name": "source_lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subtitle": { + "name": "subtitle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "source_modified_at": { + "name": "source_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ai_model": { + "name": "ai_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_provider": { + "name": "ai_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_block_snapshots": { + "name": "source_block_snapshots", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_meta_hashes": { + "name": "source_meta_hashes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ai_translations_ref_lang_uniq": { + "name": "ai_translations_ref_lang_uniq", + "columns": [ + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_translations_ref_id_idx": { + "name": "ai_translations_ref_id_idx", + "columns": [ + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.translation_entries": { + "name": "translation_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "key_path": { + "name": "key_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lookup_key": { + "name": "lookup_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_text": { + "name": "source_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "translated_text": { + "name": "translated_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_updated_at": { + "name": "source_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "translation_entries_key_uniq": { + "name": "translation_entries_key_uniq", + "columns": [ + { + "expression": "key_path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lookup_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "translation_entries_path_lang_idx": { + "name": "translation_entries_path_lang_idx", + "columns": [ + { + "expression": "key_path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "translation_entries_lookup_key_idx": { + "name": "translation_entries_lookup_key_idx", + "columns": [ + { + "expression": "lookup_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "accounts_provider_uniq": { + "name": "accounts_provider_uniq", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_readers_id_fk": { + "name": "accounts_user_id_readers_id_fk", + "tableFrom": "accounts", + "tableTo": "readers", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_keys_key_uniq": { + "name": "api_keys_key_uniq", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_user_id_idx": { + "name": "api_keys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_user_id_readers_id_fk": { + "name": "api_keys_user_id_readers_id_fk", + "tableFrom": "api_keys", + "tableTo": "readers", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_keys_reference_id_readers_id_fk": { + "name": "api_keys_reference_id_readers_id_fk", + "tableFrom": "api_keys", + "tableTo": "readers", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_codes": { + "name": "device_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_codes_device_code_uniq": { + "name": "device_codes_device_code_uniq", + "columns": [ + { + "expression": "device_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_codes_user_code_uniq": { + "name": "device_codes_user_code_uniq", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_codes_expires_at_idx": { + "name": "device_codes_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_codes_user_id_readers_id_fk": { + "name": "device_codes_user_id_readers_id_fk", + "tableFrom": "device_codes", + "tableTo": "readers", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.owner_profiles": { + "name": "owner_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reader_id": { + "name": "reader_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mail": { + "name": "mail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "introduce": { + "name": "introduce", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_login_ip": { + "name": "last_login_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_login_time": { + "name": "last_login_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "social_ids": { + "name": "social_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "owner_profiles_reader_id_uniq": { + "name": "owner_profiles_reader_id_uniq", + "columns": [ + { + "expression": "reader_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "owner_profiles_reader_id_readers_id_fk": { + "name": "owner_profiles_reader_id_readers_id_fk", + "tableFrom": "owner_profiles", + "tableTo": "readers", + "columnsFrom": [ + "reader_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkeys": { + "name": "passkeys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkeys_credential_id_uniq": { + "name": "passkeys_credential_id_uniq", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkeys_user_id_idx": { + "name": "passkeys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkeys_user_id_readers_id_fk": { + "name": "passkeys_user_id_readers_id_fk", + "tableFrom": "passkeys", + "tableTo": "readers", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.readers": { + "name": "readers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reader'" + }, + "banned_at": { + "name": "banned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "readers_email_uniq": { + "name": "readers_email_uniq", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"readers\".\"email\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "readers_username_uniq": { + "name": "readers_username_uniq", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"readers\".\"username\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "readers_role_idx": { + "name": "readers_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_token_uniq": { + "name": "sessions_token_uniq", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_readers_id_fk": { + "name": "sessions_user_id_readers_id_fk", + "tableFrom": "sessions", + "tableTo": "readers", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_webhook_events": { + "name": "billing_webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_webhook_events_provider_event_id_uniq": { + "name": "billing_webhook_events_provider_event_id_uniq", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reader_id": { + "name": "reader_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_customer_id": { + "name": "provider_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "memberships_reader_id_uniq": { + "name": "memberships_reader_id_uniq", + "columns": [ + { + "expression": "reader_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memberships_provider_subscription_id_uniq": { + "name": "memberships_provider_subscription_id_uniq", + "columns": [ + { + "expression": "provider_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"memberships\".\"provider_subscription_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_reader_id_readers_id_fk": { + "name": "memberships_reader_id_readers_id_fk", + "tableFrom": "memberships", + "tableTo": "readers", + "columnsFrom": [ + "reader_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companion_devices": { + "name": "companion_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "presence_cleared_at": { + "name": "presence_cleared_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "companion_devices_token_hash_uniq": { + "name": "companion_devices_token_hash_uniq", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "companion_devices_owner_created_idx": { + "name": "companion_devices_owner_created_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "companion_devices_pending_presence_clear_idx": { + "name": "companion_devices_pending_presence_clear_idx", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"companion_devices\".\"revoked_at\" is not null and \"companion_devices\".\"presence_cleared_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "companion_devices_owner_id_readers_id_fk": { + "name": "companion_devices_owner_id_readers_id_fk", + "tableFrom": "companion_devices", + "tableTo": "readers", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "companion_devices_token_hash_hex_check": { + "name": "companion_devices_token_hash_hex_check", + "value": "\"companion_devices\".\"token_hash\" ~ '^[0-9a-f]{64}$'" + }, + "companion_devices_scopes_array_check": { + "name": "companion_devices_scopes_array_check", + "value": "jsonb_typeof(\"companion_devices\".\"scopes\") = 'array'" + } + }, + "isRLSEnabled": false + }, + "public.companion_pairings": { + "name": "companion_pairings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "companion_pairings_code_hash_uniq": { + "name": "companion_pairings_code_hash_uniq", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "companion_pairings_owner_created_idx": { + "name": "companion_pairings_owner_created_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "companion_pairings_expires_at_idx": { + "name": "companion_pairings_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "companion_pairings_owner_id_readers_id_fk": { + "name": "companion_pairings_owner_id_readers_id_fk", + "tableFrom": "companion_pairings", + "tableTo": "readers", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "companion_pairings_code_hash_hex_check": { + "name": "companion_pairings_code_hash_hex_check", + "value": "\"companion_pairings\".\"code_hash\" ~ '^[0-9a-f]{64}$'" + }, + "companion_pairings_scopes_array_check": { + "name": "companion_pairings_scopes_array_check", + "value": "jsonb_typeof(\"companion_pairings\".\"scopes\") = 'array'" + }, + "companion_pairings_expiry_check": { + "name": "companion_pairings_expiry_check", + "value": "\"companion_pairings\".\"expires_at\" > \"companion_pairings\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "categories_name_uniq": { + "name": "categories_name_uniq", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "categories_slug_uniq": { + "name": "categories_slug_uniq", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mail": { + "name": "mail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_comment_id": { + "name": "root_comment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_count": { + "name": "reply_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "latest_reply_at": { + "name": "latest_reply_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_whispers": { + "name": "is_whispers", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reader_id": { + "name": "reader_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "edited_at": { + "name": "edited_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "anchor": { + "name": "anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_owner_reply": { + "name": "is_owner_reply", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "comments_thread_idx": { + "name": "comments_thread_idx", + "columns": [ + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_root_idx": { + "name": "comments_root_idx", + "columns": [ + { + "expression": "root_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_reader_idx": { + "name": "comments_reader_idx", + "columns": [ + { + "expression": "reader_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_parent_comment_id_comments_id_fk": { + "name": "comments_parent_comment_id_comments_id_fk", + "tableFrom": "comments", + "tableTo": "comments", + "columnsFrom": [ + "parent_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_root_comment_id_comments_id_fk": { + "name": "comments_root_comment_id_comments_id_fk", + "tableFrom": "comments", + "tableTo": "comments", + "columnsFrom": [ + "root_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_reader_id_readers_id_fk": { + "name": "comments_reader_id_readers_id_fk", + "tableFrom": "comments", + "tableTo": "readers", + "columnsFrom": [ + "reader_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.draft_histories": { + "name": "draft_histories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "draft_id": { + "name": "draft_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type_specific_data": { + "name": "type_specific_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "saved_at": { + "name": "saved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_full_snapshot": { + "name": "is_full_snapshot", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "ref_version": { + "name": "ref_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "base_version": { + "name": "base_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "draft_histories_draft_version_uniq": { + "name": "draft_histories_draft_version_uniq", + "columns": [ + { + "expression": "draft_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "draft_histories_draft_id_drafts_id_fk": { + "name": "draft_histories_draft_id_drafts_id_fk", + "tableFrom": "draft_histories", + "tableTo": "drafts", + "columnsFrom": [ + "draft_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.drafts": { + "name": "drafts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type_specific_data": { + "name": "type_specific_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "history": { + "name": "history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "published_version": { + "name": "published_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "drafts_ref_uniq": { + "name": "drafts_ref_uniq", + "columns": [ + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"drafts\".\"ref_id\" is not null", + "concurrently": true, + "method": "btree", + "with": {} + }, + "drafts_ref_idx": { + "name": "drafts_ref_idx", + "columns": [ + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"drafts\".\"ref_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "drafts_updated_at_idx": { + "name": "drafts_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "nid": { + "name": "nid", + "type": "integer", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "notes_nid_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_at": { + "name": "public_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "mood": { + "name": "mood", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weather": { + "name": "weather", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bookmark": { + "name": "bookmark", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "coordinates": { + "name": "coordinates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "read_count": { + "name": "read_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "like_count": { + "name": "like_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "notes_nid_uniq": { + "name": "notes_nid_uniq", + "columns": [ + { + "expression": "nid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notes_slug_uniq": { + "name": "notes_slug_uniq", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notes\".\"slug\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notes_nid_desc_idx": { + "name": "notes_nid_desc_idx", + "columns": [ + { + "expression": "nid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notes_modified_at_idx": { + "name": "notes_modified_at_idx", + "columns": [ + { + "expression": "modified_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notes_created_at_idx": { + "name": "notes_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notes_topic_id_idx": { + "name": "notes_topic_id_idx", + "columns": [ + { + "expression": "topic_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notes_published_public_created_idx": { + "name": "notes_published_public_created_idx", + "columns": [ + { + "expression": "is_published", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "public_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notes_topic_id_topics_id_fk": { + "name": "notes_topic_id_topics_id_fk", + "tableFrom": "notes", + "tableTo": "topics", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pages": { + "name": "pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subtitle": { + "name": "subtitle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pages_slug_uniq": { + "name": "pages_slug_uniq", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_order_idx": { + "name": "pages_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_related_posts": { + "name": "post_related_posts", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "related_post_id": { + "name": "related_post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "post_related_posts_pk": { + "name": "post_related_posts_pk", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "related_post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_related_posts_related_idx": { + "name": "post_related_posts_related_idx", + "columns": [ + { + "expression": "related_post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_related_posts_post_id_posts_id_fk": { + "name": "post_related_posts_post_id_posts_id_fk", + "tableFrom": "post_related_posts", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_related_posts_related_post_id_posts_id_fk": { + "name": "post_related_posts_related_post_id_posts_id_fk", + "tableFrom": "post_related_posts", + "tableTo": "posts", + "columnsFrom": [ + "related_post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "copyright": { + "name": "copyright", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_premium": { + "name": "is_premium", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "read_count": { + "name": "read_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "like_count": { + "name": "like_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pin_at": { + "name": "pin_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pin_order": { + "name": "pin_order", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "posts_slug_uniq": { + "name": "posts_slug_uniq", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_modified_at_idx": { + "name": "posts_modified_at_idx", + "columns": [ + { + "expression": "modified_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_created_at_idx": { + "name": "posts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_category_id_idx": { + "name": "posts_category_id_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_published_created_at_idx": { + "name": "posts_published_created_at_idx", + "columns": [ + { + "expression": "is_published", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pin_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "posts_category_published_created_idx": { + "name": "posts_category_published_created_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_published", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pin_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "posts_tags_gin_idx": { + "name": "posts_tags_gin_idx", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "posts_category_id_categories_id_fk": { + "name": "posts_category_id_categories_id_fk", + "tableFrom": "posts", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recentlies": { + "name": "recentlies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comments_index": { + "name": "comments_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_comment": { + "name": "allow_comment", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "up": { + "name": "up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "down": { + "name": "down", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "recentlies_ref_idx": { + "name": "recentlies_ref_idx", + "columns": [ + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recentlies_created_at_idx": { + "name": "recentlies_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.topics": { + "name": "topics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "introduce": { + "name": "introduce", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "topics_name_uniq": { + "name": "topics_name_uniq", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_slug_uniq": { + "name": "topics_slug_uniq", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.enrichment_cache": { + "name": "enrichment_cache", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "normalized": { + "name": "normalized", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "enrichment_provider_external_id_locale_uniq": { + "name": "enrichment_provider_external_id_locale_uniq", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locale", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "enrichment_expires_at_idx": { + "name": "enrichment_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.enrichment_captures": { + "name": "enrichment_captures", + "schema": "", + "columns": { + "enrichment_id": { + "name": "enrichment_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "thumbhash": { + "name": "thumbhash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "palette": { + "name": "palette", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "enrichment_captures_lru_idx": { + "name": "enrichment_captures_lru_idx", + "columns": [ + { + "expression": "last_accessed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "enrichment_captures_enrichment_id_enrichment_cache_id_fk": { + "name": "enrichment_captures_enrichment_id_enrichment_cache_id_fk", + "tableFrom": "enrichment_captures", + "tableTo": "enrichment_cache", + "columnsFrom": [ + "enrichment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._app_migrations": { + "name": "_app_migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_id_map": { + "name": "auth_id_map", + "schema": "", + "columns": { + "collection": { + "name": "collection", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mongo_id": { + "name": "mongo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pg_id": { + "name": "pg_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_id_map_collection_mongo_uniq": { + "name": "auth_id_map_collection_mongo_uniq", + "columns": [ + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mongo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_id_map_collection_pg_uniq": { + "name": "auth_id_map_collection_pg_uniq", + "columns": [ + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pg_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_migration_runs": { + "name": "data_migration_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mongo_id_map": { + "name": "mongo_id_map", + "schema": "", + "columns": { + "collection": { + "name": "collection", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mongo_id": { + "name": "mongo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snowflake_id": { + "name": "snowflake_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mongo_id_map_pk": { + "name": "mongo_id_map_pk", + "columns": [ + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mongo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mongo_id_map_snowflake_uniq": { + "name": "mongo_id_map_snowflake_uniq", + "columns": [ + { + "expression": "snowflake_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schema_migrations": { + "name": "schema_migrations", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activities": { + "name": "activities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "type": { + "name": "type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "activities_created_at_idx": { + "name": "activities_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.analyzes": { + "name": "analyzes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ua": { + "name": "ua", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referer": { + "name": "referer", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "analyzes_timestamp_idx": { + "name": "analyzes_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analyzes_timestamp_path_idx": { + "name": "analyzes_timestamp_path_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analyzes_timestamp_referer_idx": { + "name": "analyzes_timestamp_referer_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "referer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analyzes_timestamp_ip_idx": { + "name": "analyzes_timestamp_ip_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ip", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_references": { + "name": "file_references", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "s3_object_key": { + "name": "s3_object_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reader_id": { + "name": "reader_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "detached_at": { + "name": "detached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "file_references_file_url_idx": { + "name": "file_references_file_url_idx", + "columns": [ + { + "expression": "file_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_references_ref_idx": { + "name": "file_references_ref_idx", + "columns": [ + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_references_status_created_idx": { + "name": "file_references_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_references_reader_status_created_idx": { + "name": "file_references_reader_status_created_idx", + "columns": [ + { + "expression": "reader_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_references_status_detached_idx": { + "name": "file_references_status_detached_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "detached_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_references_reader_id_readers_id_fk": { + "name": "file_references_reader_id_readers_id_fk", + "tableFrom": "file_references", + "tableTo": "readers", + "columnsFrom": [ + "reader_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_usages": { + "name": "file_usages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "file_reference_id": { + "name": "file_reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_field": { + "name": "source_field", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "file_usages_reference_source_uniq": { + "name": "file_usages_reference_source_uniq", + "columns": [ + { + "expression": "file_reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_field", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_usages_source_idx": { + "name": "file_usages_source_idx", + "columns": [ + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_usages_file_reference_id_file_references_id_fk": { + "name": "file_usages_file_reference_id_file_references_id_fk", + "tableFrom": "file_usages", + "tableTo": "file_references", + "columnsFrom": [ + "file_reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.links": { + "name": "links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "links_name_uniq": { + "name": "links_name_uniq", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "links_url_uniq": { + "name": "links_url_uniq", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meta_presets": { + "name": "meta_presets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "meta_presets_name_uniq": { + "name": "meta_presets_name_uniq", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.options": { + "name": "options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "options_name_uniq": { + "name": "options_name_uniq", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.poll_vote_options": { + "name": "poll_vote_options", + "schema": "", + "columns": { + "vote_id": { + "name": "vote_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "option_id": { + "name": "option_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "poll_vote_options_pk": { + "name": "poll_vote_options_pk", + "columns": [ + { + "expression": "vote_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "poll_vote_options_option_idx": { + "name": "poll_vote_options_option_idx", + "columns": [ + { + "expression": "option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "poll_vote_options_vote_id_poll_votes_id_fk": { + "name": "poll_vote_options_vote_id_poll_votes_id_fk", + "tableFrom": "poll_vote_options", + "tableTo": "poll_votes", + "columnsFrom": [ + "vote_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.poll_votes": { + "name": "poll_votes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "poll_id": { + "name": "poll_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voter_fingerprint": { + "name": "voter_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "poll_votes_poll_voter_uniq": { + "name": "poll_votes_poll_voter_uniq", + "columns": [ + { + "expression": "poll_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "voter_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "poll_votes_poll_id_idx": { + "name": "poll_votes_poll_id_idx", + "columns": [ + { + "expression": "poll_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview_url": { + "name": "preview_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "doc_url": { + "name": "doc_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_url": { + "name": "project_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "projects_name_uniq": { + "name": "projects_name_uniq", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.says": { + "name": "says", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "says_created_at_idx": { + "name": "says_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.serverless_logs": { + "name": "serverless_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "function_id": { + "name": "function_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_time": { + "name": "execution_time", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "logs": { + "name": "logs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "serverless_logs_created_at_idx": { + "name": "serverless_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "serverless_logs_function_idx": { + "name": "serverless_logs_function_idx", + "columns": [ + { + "expression": "function_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "serverless_logs_reference_idx": { + "name": "serverless_logs_reference_idx", + "columns": [ + { + "expression": "reference", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.serverless_storages": { + "name": "serverless_storages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "serverless_storages_ns_key_uniq": { + "name": "serverless_storages_ns_key_uniq", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slug_trackers": { + "name": "slug_trackers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "slug_trackers_type_target_idx": { + "name": "slug_trackers_type_target_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slug_trackers_slug_type_idx": { + "name": "slug_trackers_slug_type_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.snippets": { + "name": "snippets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "raw": { + "name": "raw", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metatype": { + "name": "metatype", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable": { + "name": "enable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "built_in": { + "name": "built_in", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "compiled_code": { + "name": "compiled_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "snippets_path_prefix_idx": { + "name": "snippets_path_prefix_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "snippets_type_idx": { + "name": "snippets_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "snippets_path_idx": { + "name": "snippets_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"snippets\".\"method\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "snippets_path_method_idx": { + "name": "snippets_path_method_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "method", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"snippets\".\"method\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscribes": { + "name": "subscribes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cancel_token": { + "name": "cancel_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subscribe": { + "name": "subscribe", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "subscribes_email_uniq": { + "name": "subscribes_email_uniq", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscribes_cancel_token_uniq": { + "name": "subscribes_cancel_token_uniq", + "columns": [ + { + "expression": "cancel_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_events": { + "name": "webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "hook_id": { + "name": "hook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "webhook_events_hook_id_idx": { + "name": "webhook_events_hook_id_idx", + "columns": [ + { + "expression": "hook_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_timestamp_idx": { + "name": "webhook_events_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_events_hook_id_webhooks_id_fk": { + "name": "webhook_events_hook_id_webhooks_id_fk", + "tableFrom": "webhook_events", + "tableTo": "webhooks", + "columnsFrom": [ + "hook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload_url": { + "name": "payload_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "events": { + "name": "events", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "webhooks_enabled_idx": { + "name": "webhooks_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.search_documents": { + "name": "search_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "terms": { + "name": "terms", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "title_term_freq": { + "name": "title_term_freq", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "body_term_freq": { + "name": "body_term_freq", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "title_length": { + "name": "title_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "body_length": { + "name": "body_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nid": { + "name": "nid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "public_at": { + "name": "public_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "has_password": { + "name": "has_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "search_documents_ref_lang_uniq": { + "name": "search_documents_ref_lang_uniq", + "columns": [ + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "search_documents_published_idx": { + "name": "search_documents_published_idx", + "columns": [ + { + "expression": "is_published", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "public_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "search_documents_lang_idx": { + "name": "search_documents_lang_idx", + "columns": [ + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_tts": { + "name": "ai_tts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_translation": { + "name": "is_translation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_lang": { + "name": "source_lang", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voice": { + "name": "voice", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "speed": { + "name": "speed", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mp3'" + }, + "block_order": { + "name": "block_order", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "char_count": { + "name": "char_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source_modified_at": { + "name": "source_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ai_tts_ref_lang_uniq": { + "name": "ai_tts_ref_lang_uniq", + "columns": [ + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_tts_ref_id_idx": { + "name": "ai_tts_ref_id_idx", + "columns": [ + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_tts_blocks": { + "name": "ai_tts_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "tts_id": { + "name": "tts_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_backend": { + "name": "storage_backend", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ai_tts_blocks_key_uniq": { + "name": "ai_tts_blocks_key_uniq", + "columns": [ + { + "expression": "tts_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_tts_blocks_tts_id_idx": { + "name": "ai_tts_blocks_tts_id_idx", + "columns": [ + { + "expression": "tts_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_tts_blocks_tts_id_ai_tts_id_fk": { + "name": "ai_tts_blocks_tts_id_ai_tts_id_fk", + "tableFrom": "ai_tts_blocks", + "tableTo": "ai_tts", + "columnsFrom": [ + "tts_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/core/src/database/migrations/meta/_journal.json b/apps/core/src/database/migrations/meta/_journal.json index 76b0685badc..a42171dbf62 100644 --- a/apps/core/src/database/migrations/meta/_journal.json +++ b/apps/core/src/database/migrations/meta/_journal.json @@ -1,202 +1 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1777748001246, - "tag": "0000_initial", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1777827147946, - "tag": "0001_even_professor_monster", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1777888380921, - "tag": "0002_add_reader_id_fks", - "breakpoints": true - }, - { - "idx": 3, - "version": "7", - "when": 1778025826000, - "tag": "0003_passkey_transports_text", - "breakpoints": true - }, - { - "idx": 4, - "version": "7", - "when": 1778026000000, - "tag": "0004_heavy_siren", - "breakpoints": true - }, - { - "idx": 5, - "version": "7", - "when": 1778069140607, - "tag": "0005_app_migrations_ledger", - "breakpoints": true - }, - { - "idx": 6, - "version": "7", - "when": 1778159900719, - "tag": "0006_enrichment_locale_column", - "breakpoints": true - }, - { - "idx": 7, - "version": "7", - "when": 1778159977902, - "tag": "0007_enrichment_locale_index_swap", - "breakpoints": true - }, - { - "idx": 8, - "version": "7", - "when": 1778176563246, - "tag": "0008_enrichment_clear_stale_errors", - "breakpoints": true - }, - { - "idx": 9, - "version": "7", - "when": 1778347379676, - "tag": "0009_search_documents_multilang", - "breakpoints": true - }, - { - "idx": 10, - "version": "7", - "when": 1778433600000, - "tag": "0010_notes_nid_identity", - "breakpoints": true - }, - { - "idx": 11, - "version": "7", - "when": 1778522427325, - "tag": "0011_enrichment_screenshots", - "breakpoints": true - }, - { - "idx": 12, - "version": "7", - "when": 1778950378766, - "tag": "0012_blushing_falcon", - "breakpoints": true - }, - { - "idx": 13, - "version": "7", - "when": 1779035095334, - "tag": "0013_device_codes_table", - "breakpoints": true - }, - { - "idx": 14, - "version": "7", - "when": 1779380000000, - "tag": "0014_enrichment_captures", - "breakpoints": true - }, - { - "idx": 15, - "version": "7", - "when": 1779900207819, - "tag": "0015_blurhash_to_thumbhash", - "breakpoints": true - }, - { - "idx": 16, - "version": "7", - "when": 1779990739617, - "tag": "0016_milky_mystique", - "breakpoints": true - }, - { - "idx": 17, - "version": "7", - "when": 1780099200000, - "tag": "0017_ai_provider_type_collapse", - "breakpoints": true - }, - { - "idx": 18, - "version": "7", - "when": 1780358400000, - "tag": "0018_ai_agent_conversations_rewrite", - "breakpoints": true - }, - { - "idx": 19, - "version": "7", - "when": 1780531200000, - "tag": "0019_ai_agent_conversation_title", - "breakpoints": true - }, - { - "idx": 20, - "version": "7", - "when": 1780704000000, - "tag": "0020_comments_owner_reply_and_country", - "breakpoints": true - }, - { - "idx": 21, - "version": "7", - "when": 1781113042176, - "tag": "0021_normalize-ai-lang-keys", - "breakpoints": true - }, - { - "idx": 22, - "version": "7", - "when": 1781935200000, - "tag": "0022_snippet_vfs", - "breakpoints": true - }, - { - "idx": 23, - "version": "7", - "when": 1784178270027, - "tag": "0023_companion_devices_pairings", - "breakpoints": true - }, - { - "idx": 24, - "version": "7", - "when": 1784371077025, - "tag": "0024_membership_billing_tables", - "breakpoints": true - }, - { - "idx": 25, - "version": "7", - "when": 1785226261918, - "tag": "0025_drafts_ref_unique", - "breakpoints": true - }, - { - "idx": 26, - "version": "7", - "when": 1785929805444, - "tag": "0026_overconfident_medusa", - "breakpoints": true - }, - { - "idx": 27, - "version": "7", - "when": 1786088795437, - "tag": "0027_clean_vindicator", - "breakpoints": true - } - ] -} \ No newline at end of file +{"version":"7","dialect":"postgresql","entries":[{"idx":0,"version":"7","when":1777748001246,"tag":"0000_initial","breakpoints":true},{"idx":1,"version":"7","when":1777827147946,"tag":"0001_even_professor_monster","breakpoints":true},{"idx":2,"version":"7","when":1777888380921,"tag":"0002_add_reader_id_fks","breakpoints":true},{"idx":3,"version":"7","when":1778025826000,"tag":"0003_passkey_transports_text","breakpoints":true},{"idx":4,"version":"7","when":1778026000000,"tag":"0004_heavy_siren","breakpoints":true},{"idx":5,"version":"7","when":1778069140607,"tag":"0005_app_migrations_ledger","breakpoints":true},{"idx":6,"version":"7","when":1778159900719,"tag":"0006_enrichment_locale_column","breakpoints":true},{"idx":7,"version":"7","when":1778159977902,"tag":"0007_enrichment_locale_index_swap","breakpoints":true},{"idx":8,"version":"7","when":1778176563246,"tag":"0008_enrichment_clear_stale_errors","breakpoints":true},{"idx":9,"version":"7","when":1778347379676,"tag":"0009_search_documents_multilang","breakpoints":true},{"idx":10,"version":"7","when":1778433600000,"tag":"0010_notes_nid_identity","breakpoints":true},{"idx":11,"version":"7","when":1778522427325,"tag":"0011_enrichment_screenshots","breakpoints":true},{"idx":12,"version":"7","when":1778950378766,"tag":"0012_blushing_falcon","breakpoints":true},{"idx":13,"version":"7","when":1779035095334,"tag":"0013_device_codes_table","breakpoints":true},{"idx":14,"version":"7","when":1779380000000,"tag":"0014_enrichment_captures","breakpoints":true},{"idx":15,"version":"7","when":1779900207819,"tag":"0015_blurhash_to_thumbhash","breakpoints":true},{"idx":16,"version":"7","when":1779990739617,"tag":"0016_milky_mystique","breakpoints":true},{"idx":17,"version":"7","when":1780099200000,"tag":"0017_ai_provider_type_collapse","breakpoints":true},{"idx":18,"version":"7","when":1780358400000,"tag":"0018_ai_agent_conversations_rewrite","breakpoints":true},{"idx":19,"version":"7","when":1780531200000,"tag":"0019_ai_agent_conversation_title","breakpoints":true},{"idx":20,"version":"7","when":1780704000000,"tag":"0020_comments_owner_reply_and_country","breakpoints":true},{"idx":21,"version":"7","when":1781113042176,"tag":"0021_normalize-ai-lang-keys","breakpoints":true},{"idx":22,"version":"7","when":1781935200000,"tag":"0022_snippet_vfs","breakpoints":true},{"idx":23,"version":"7","when":1784178270027,"tag":"0023_companion_devices_pairings","breakpoints":true},{"idx":24,"version":"7","when":1784371077025,"tag":"0024_membership_billing_tables","breakpoints":true},{"idx":25,"version":"7","when":1785226261918,"tag":"0025_drafts_ref_unique","breakpoints":true},{"idx":26,"version":"7","when":1785929805444,"tag":"0026_overconfident_medusa","breakpoints":true},{"idx":27,"version":"7","when":1786088795437,"tag":"0027_clean_vindicator","breakpoints":true},{"idx":28,"version":"7","when":1785929900000,"tag":"0028_ai_tts","breakpoints":true}]} \ No newline at end of file diff --git a/apps/core/src/modules/ai/ai-article-visibility.util.ts b/apps/core/src/modules/ai/ai-article-visibility.util.ts index c32961099d0..6c0ee3a5f2c 100644 --- a/apps/core/src/modules/ai/ai-article-visibility.util.ts +++ b/apps/core/src/modules/ai/ai-article-visibility.util.ts @@ -6,23 +6,46 @@ import type { PostModel } from '../post/post.types' type VisibilityArticle = { type: CollectionRefTypes; document: unknown } +export interface ArticleViewer { + /** The authenticated site owner — sees drafts, secrets and protected notes. */ + isOwner?: boolean + /** The reader supplied the note's password and it verified. */ + hasNotePassword?: boolean +} + +// `NoteRepository.mapBase` projects the column to `hasPassword` and drops the +// secret itself, so a document loaded through `findGlobalById` never carries +// `password` — checking that field alone silently admits protected notes. +function noteIsPasswordProtected( + document: NoteModel & { hasPassword?: boolean }, +): boolean { + return Boolean(document.password) || Boolean(document.hasPassword) +} + /** - * Whether an article is publicly visible (published, not password-protected, - * not a future-dated note secret). Pages are always visible. Recently entries - * are never treated as visible articles. + * Whether an article is visible to the given viewer (published, not + * password-protected, not a future-dated note secret). Pages are always + * visible. Recently entries are never treated as visible articles. * - * Shared by every AI feature (summary, insights, translation) so public + * Shared by every AI feature (summary, insights, translation, tts) so public * endpoints never leak draft or protected content. */ -export function isGlobalArticleVisible(article: VisibilityArticle): boolean { +export function isArticleVisibleToViewer( + article: VisibilityArticle, + viewer: ArticleViewer, +): boolean { if (article.type === CollectionRefTypes.Post) { + if (viewer.isOwner) return true return (article.document as PostModel).isPublished !== false } if (article.type === CollectionRefTypes.Note) { - const document = article.document as NoteModel + const document = article.document as NoteModel & { hasPassword?: boolean } + if (viewer.isOwner) return true if (document.isPublished === false) return false - if (document.password) return false + if (noteIsPasswordProtected(document) && !viewer.hasNotePassword) { + return false + } if (isNoteSecret(document)) return false return true } @@ -33,3 +56,7 @@ export function isGlobalArticleVisible(article: VisibilityArticle): boolean { return false } + +export function isGlobalArticleVisible(article: VisibilityArticle): boolean { + return isArticleVisibleToViewer(article, {}) +} diff --git a/apps/core/src/modules/ai/ai-task/ai-task.service.ts b/apps/core/src/modules/ai/ai-task/ai-task.service.ts index 33cc58b2597..3dc285e00e9 100644 --- a/apps/core/src/modules/ai/ai-task/ai-task.service.ts +++ b/apps/core/src/modules/ai/ai-task/ai-task.service.ts @@ -16,6 +16,7 @@ import { type TranslationAllTaskPayload, type TranslationBatchTaskPayload, type TranslationTaskPayload, + type TtsTaskPayload, } from './ai-task.types' @Injectable() @@ -77,6 +78,13 @@ export class AiTaskService { return this.createTask(AITaskType.ImageGeneration, payload) } + async createTtsTask( + payload: TtsTaskPayload, + ): Promise<{ taskId: string; created: boolean }> { + await this.fillArticleInfo(payload) + return this.createTask(AITaskType.Tts, payload) + } + private async createTask( type: AITaskType, payload: AITaskPayload, diff --git a/apps/core/src/modules/ai/ai-task/ai-task.types.ts b/apps/core/src/modules/ai/ai-task/ai-task.types.ts index 5863147e4ba..b06b115bc49 100644 --- a/apps/core/src/modules/ai/ai-task/ai-task.types.ts +++ b/apps/core/src/modules/ai/ai-task/ai-task.types.ts @@ -1,3 +1,5 @@ +import { parseLanguageCode } from '../ai-language.util' + export enum AITaskType { Summary = 'ai:summary', Translation = 'ai:translation', @@ -7,6 +9,7 @@ export enum AITaskType { Insights = 'ai:insights', InsightsTranslation = 'ai:insights:translation', ImageGeneration = 'ai:image:generation', + Tts = 'ai:tts', } export interface SummaryTaskPayload { @@ -73,6 +76,14 @@ export interface ImageGenerationTaskPayload { requestId: string } +export interface TtsTaskPayload { + refId: string + langs?: string[] + force?: boolean + title?: string + refType?: string +} + export type AITaskPayload = | SummaryTaskPayload | TranslationTaskPayload @@ -82,6 +93,7 @@ export type AITaskPayload = | InsightsTaskPayload | InsightsTranslationTaskPayload | ImageGenerationTaskPayload + | TtsTaskPayload export function computeAITaskDedupKey( type: AITaskType, @@ -126,5 +138,17 @@ export function computeAITaskDedupKey( const p = payload as ImageGenerationTaskPayload return `${p.requestId}` } + case AITaskType.Tts: { + const p = payload as TtsTaskPayload + // The handler locks on canonical (refId, lang), so the dedup key has to + // canonicalize too — otherwise `zh-CN` and `zh` enqueue two tasks and the + // one that loses the lock reports success having generated nothing. + const langs = [ + ...new Set((p.langs || []).map((lang) => parseLanguageCode(lang))), + ] + .sort() + .join(',') + return `${p.refId}:${p.force ? 'force' : 'inc'}:${langs}` + } } } diff --git a/apps/core/src/modules/ai/ai-translation/article-content.util.ts b/apps/core/src/modules/ai/ai-translation/article-content.util.ts new file mode 100644 index 00000000000..858ee45cf03 --- /dev/null +++ b/apps/core/src/modules/ai/ai-translation/article-content.util.ts @@ -0,0 +1,22 @@ +import type { ArticleContent, ArticleDocument } from './ai-translation.types' + +export function toArticleContent(document: ArticleDocument): ArticleContent { + return { + title: document.title, + text: document.text, + subtitle: + 'subtitle' in document ? (document.subtitle ?? undefined) : undefined, + summary: + 'summary' in document ? (document.summary ?? undefined) : undefined, + tags: 'tags' in document ? document.tags : undefined, + contentFormat: document.contentFormat, + content: document.content, + } +} + +export function readArticleMetaLang(document: { + meta?: Record | null +}): string | undefined { + const lang = document.meta?.lang + return typeof lang === 'string' ? lang : undefined +} diff --git a/apps/core/src/modules/ai/ai-translation/base-translation.service.ts b/apps/core/src/modules/ai/ai-translation/base-translation.service.ts index 32e53e36281..7322d49b4c4 100644 --- a/apps/core/src/modules/ai/ai-translation/base-translation.service.ts +++ b/apps/core/src/modules/ai/ai-translation/base-translation.service.ts @@ -10,27 +10,17 @@ import type { ArticleDocument, GlobalArticle, } from './ai-translation.types' +import { readArticleMetaLang, toArticleContent } from './article-content.util' export abstract class BaseTranslationService { toArticleContent(document: ArticleDocument): ArticleContent { - return { - title: document.title, - text: document.text, - subtitle: - 'subtitle' in document ? (document.subtitle ?? undefined) : undefined, - summary: - 'summary' in document ? (document.summary ?? undefined) : undefined, - tags: 'tags' in document ? document.tags : undefined, - contentFormat: document.contentFormat, - content: document.content, - } + return toArticleContent(document) } getMetaLang(document: { meta?: Record | null }): string | undefined { - const lang = document.meta?.lang - return typeof lang === 'string' ? lang : undefined + return readArticleMetaLang(document) } computeContentHash(document: ArticleContent, sourceLang: string): string { diff --git a/apps/core/src/modules/ai/ai-tts/ai-tts-query.service.ts b/apps/core/src/modules/ai/ai-tts/ai-tts-query.service.ts new file mode 100644 index 00000000000..4c45bf6a9dd --- /dev/null +++ b/apps/core/src/modules/ai/ai-tts/ai-tts-query.service.ts @@ -0,0 +1,269 @@ +import { Inject, Injectable } from '@nestjs/common' + +import type { ArticleRefMap, TtsMeta } from '~/common/response/meta.types' +import { CollectionRefTypes } from '~/constants/db.constant' +import { NOTE_SERVICE_TOKEN } from '~/constants/injection.constant' +import type { PaginationResult } from '~/processors/database/base.repository' +import { DatabaseService } from '~/processors/database/database.service' + +import { EntitlementService } from '../../membership/entitlement.service' +import type { NoteService } from '../../note/note.service' +import { isArticleVisibleToViewer } from '../ai-article-visibility.util' +import { parseLanguageCode } from '../ai-language.util' +import { readArticleMetaLang } from '../ai-translation/article-content.util' +import { buildGroupedWithOrphans } from '../grouped-with-orphans.util' +import { AiTtsRepository } from './ai-tts.repository' +import type { GetTtsGroupedQueryInput } from './ai-tts.schema' +import type { AiTtsBlockRow, AiTtsRow } from './ai-tts.types' + +export interface TtsSegmentResult { + blockId: string + chunkIndex: number + text: string + url: string +} + +export interface NarrationReader { + isOwner?: boolean + readerId?: string + password?: string +} + +export interface PublicNarrationResult { + lang: string + model: string + voice: string + blockOrder: string[] + segments: TtsSegmentResult[] +} + +export interface NarrationDetailResult { + id: string + refId: string + lang: string + isTranslation: boolean + model: string + voice: string + speed: number + blockOrder: string[] + charCount: number + updatedAt: Date | null + segments: TtsSegmentResult[] +} + +export interface NarrationListItemResult { + id: string + refId: string + lang: string + blockCount: number + charCount: number + updatedAt: Date | null +} + +function toSegments( + blocks: AiTtsBlockRow[], + blockOrder: string[], +): TtsSegmentResult[] { + const rank = new Map(blockOrder.map((blockId, index) => [blockId, index])) + return blocks + .map((block) => ({ + blockId: block.blockId, + chunkIndex: block.chunkIndex, + text: block.text, + url: block.url, + })) + .sort( + (a, b) => + (rank.get(a.blockId) ?? Number.MAX_SAFE_INTEGER) - + (rank.get(b.blockId) ?? Number.MAX_SAFE_INTEGER) || + a.blockId.localeCompare(b.blockId) || + a.chunkIndex - b.chunkIndex, + ) +} + +function toListItem(row: AiTtsRow): NarrationListItemResult { + return { + id: row.id, + refId: row.refId, + lang: row.lang, + blockCount: row.blockOrder.length, + charCount: row.charCount, + updatedAt: row.updatedAt, + } +} + +@Injectable() +export class AiTtsQueryService { + constructor( + private readonly repository: AiTtsRepository, + private readonly databaseService: DatabaseService, + private readonly entitlementService: EntitlementService, + @Inject(NOTE_SERVICE_TOKEN) + private readonly noteService: NoteService, + ) {} + + private async isVisibleToReader( + article: { type: CollectionRefTypes; document: unknown }, + refId: string, + reader: NarrationReader, + ): Promise { + const hasNotePassword = + article.type === CollectionRefTypes.Note && + (await this.noteService.checkPasswordToAccess(refId, reader.password)) + + return isArticleVisibleToViewer(article, { + isOwner: reader.isOwner, + hasNotePassword, + }) + } + + async getPublicNarration( + refId: string, + lang?: string, + reader: NarrationReader = {}, + ): Promise { + const article = await this.databaseService.findGlobalById(refId) + if (!article) return null + if (!(await this.isVisibleToReader(article, refId, reader))) return null + + if ( + article.type === CollectionRefTypes.Post && + (await this.entitlementService.isPremiumLocked({ + isPremium: (article.document as { isPremium?: boolean | null }) + .isPremium, + isOwner: Boolean(reader.isOwner), + readerId: reader.readerId, + })) + ) { + return null + } + + const resolvedLang = + lang ?? + parseLanguageCode( + readArticleMetaLang( + article.document as { meta?: Record | null }, + ), + ) + + const parent = await this.repository.findByRefAndLang(refId, resolvedLang) + if (!parent || parent.blockOrder.length === 0) return null + + const blocks = await this.repository.findBlocks(parent.id) + return { + lang: parent.lang, + model: parent.model, + voice: parent.voice, + blockOrder: parent.blockOrder, + segments: toSegments(blocks, parent.blockOrder), + } + } + + private async toDetails( + parents: AiTtsRow[], + ): Promise { + if (!parents.length) return [] + const blocks = await this.repository.findBlocksByTtsIds( + parents.map((parent) => parent.id), + ) + const blocksByTtsId = blocks.reduce>( + (acc, block) => { + ;(acc[block.ttsId] ??= []).push(block) + return acc + }, + {}, + ) + return parents.map((parent) => ({ + id: parent.id, + refId: parent.refId, + lang: parent.lang, + isTranslation: parent.isTranslation, + model: parent.model, + voice: parent.voice, + speed: parent.speed, + blockOrder: parent.blockOrder, + charCount: parent.charCount, + updatedAt: parent.updatedAt, + segments: toSegments(blocksByTtsId[parent.id] ?? [], parent.blockOrder), + })) + } + + async getDetailsByRefId(refId: string): Promise { + return this.toDetails(await this.repository.findAllByRef(refId)) + } + + async getNarrationsByRefId(refId: string) { + // Unlike summaries, a missing article is not an error here: narration rows + // can outlive their article, and the write drawer fetches by ref right + // after creation. The admin detail pane tolerates a null article. + const article = await this.databaseService.findGlobalById(refId) + const rows = await this.getDetailsByRefId(refId) + return { article, rows } + } + + async getAllNarrationsGrouped(query: GetTtsGroupedQueryInput) { + const { data, pagination } = + await buildGroupedWithOrphans({ + page: query.page, + size: query.size, + search: query.search, + databaseService: this.databaseService, + fetchCandidateArticles: () => + this.databaseService.findAllArticlesForAIText(), + fetchRecordsPage: (page, size, refIds) => + this.repository.groupedByRef(page, size, refIds), + fetchRecordsDistinctRefIds: (refIds) => + this.repository.findDistinctRefIds(refIds), + fetchItemsByRefIds: async (refIds) => + this.toDetails(await this.repository.listByRefIds(refIds)), + getItemRefId: (item) => item.refId, + }) + return { + data: data.map((row) => ({ + article: row.article, + narrations: row.items, + })), + pagination, + } + } + + async list(query: { + page?: number + size?: number + }): Promise< + PaginationResult & { articles: ArticleRefMap } + > { + const result = await this.repository.listPaginated(query) + const data = result.data.map(toListItem) + return { + data, + pagination: result.pagination, + articles: await this.databaseService.getRefArticleMap( + data.map((item) => item.refId), + ), + } + } + + async getMetaForArticle( + refId: string, + lang: string, + modifiedAt?: Date | null, + ): Promise { + const row = await this.repository.findMeta(refId, lang) + // An empty block_order is the generation pipeline's "not published yet" + // sentinel — the parent row exists but the run never finalized. + if (!row || row.blockCount === 0) return { available: false } + + return { + available: true, + lang, + blockCount: row.blockCount, + stale: Boolean( + modifiedAt && + row.sourceModifiedAt && + modifiedAt.getTime() > row.sourceModifiedAt.getTime(), + ), + updatedAt: row.updatedAt, + } + } +} diff --git a/apps/core/src/modules/ai/ai-tts/ai-tts.controller.ts b/apps/core/src/modules/ai/ai-tts/ai-tts.controller.ts new file mode 100644 index 00000000000..e1c652510ac --- /dev/null +++ b/apps/core/src/modules/ai/ai-tts/ai-tts.controller.ts @@ -0,0 +1,100 @@ +import { Body, Delete, Get, Param, Post, Query } from '@nestjs/common' +import { z } from 'zod' + +import { ApiController } from '~/common/decorators/api-controller.decorator' +import { Auth } from '~/common/decorators/auth.decorator' +import { CurrentReaderId } from '~/common/decorators/current-user.decorator' +import { HasAdminAccess } from '~/common/decorators/role.decorator' +import { withMeta } from '~/common/response/envelope.types' +import { MetaObjectBuilder } from '~/common/response/meta-builder' +import { PostMetaBuilder } from '~/modules/post/post-meta-builder' +import { EntityIdDto } from '~/shared/dto/id.dto' +import { BasicPagerDto } from '~/shared/dto/pager.dto' + +import { parseLanguageCode } from '../ai-language.util' +import { AiTaskService } from '../ai-task/ai-task.service' +import { + CreateTtsTaskDto, + GetTtsGroupedQueryDto, + GetTtsQueryDto, +} from './ai-tts.schema' +import { AiTtsService } from './ai-tts.service' +import { AiTtsViews } from './ai-tts.views' +import { AiTtsQueryService } from './ai-tts-query.service' + +@ApiController('ai/tts') +export class AiTtsController { + constructor( + private readonly service: AiTtsService, + private readonly queryService: AiTtsQueryService, + private readonly taskService: AiTaskService, + ) {} + + @Post('/task') + @Auth() + createTask(@Body() body: CreateTtsTaskDto) { + return this.taskService.createTtsTask({ + refId: body.refId, + langs: body.langs?.length + ? [...new Set(body.langs.map((lang) => parseLanguageCode(lang)))] + : undefined, + force: body.force, + }) + } + + @Get('/ref/:id') + @Auth() + async getByRefId(@Param() params: EntityIdDto) { + const { article, rows } = await this.queryService.getNarrationsByRefId( + params.id, + ) + return { article, rows: z.array(AiTtsViews.detail).parse(rows) } + } + + @Get('/grouped') + @Auth() + async listGrouped(@Query() query: GetTtsGroupedQueryDto) { + const result = await this.queryService.getAllNarrationsGrouped(query) + return withMeta( + result.data.map((group) => ({ + article: group.article, + narrations: z.array(AiTtsViews.detail).parse(group.narrations), + })), + new MetaObjectBuilder().pagination(result.pagination).build(), + ) + } + + @Get('/') + @Auth() + async list(@Query() query: BasicPagerDto) { + const result = await this.queryService.list(query) + return withMeta( + z.array(AiTtsViews.listItem).parse(result.data), + new PostMetaBuilder() + .pagination(result.pagination) + .articles(result.articles) + .build(), + ) + } + + @Delete('/:id') + @Auth() + delete(@Param() params: EntityIdDto) { + return this.service.deleteById(params.id) + } + + @Get('/article/:id') + async getArticleTts( + @Param() params: EntityIdDto, + @Query() query: GetTtsQueryDto, + @HasAdminAccess() isAuthenticated?: boolean, + @CurrentReaderId() readerId?: string, + ) { + const result = await this.queryService.getPublicNarration( + params.id, + query.lang ? parseLanguageCode(query.lang) : undefined, + { isOwner: Boolean(isAuthenticated), password: query.password, readerId }, + ) + return result ? AiTtsViews.public.parse(result) : null + } +} diff --git a/apps/core/src/modules/ai/ai-tts/ai-tts.repository.ts b/apps/core/src/modules/ai/ai-tts/ai-tts.repository.ts new file mode 100644 index 00000000000..9def82198be --- /dev/null +++ b/apps/core/src/modules/ai/ai-tts/ai-tts.repository.ts @@ -0,0 +1,360 @@ +import { Inject, Injectable } from '@nestjs/common' +import { and, desc, eq, inArray, sql } from 'drizzle-orm' + +import { PG_DB_TOKEN } from '~/constants/system.constant' +import { aiTts, aiTtsBlocks } from '~/database/schema' +import { + BaseRepository, + type PaginationResult, + toEntityId, +} from '~/processors/database/base.repository' +import type { AppDatabase } from '~/processors/database/postgres.provider' +import { type EntityId, parseEntityId } from '~/shared/id/entity-id' +import { SnowflakeService } from '~/shared/id/snowflake.service' + +import type { + AiTtsBlockRow, + AiTtsMeta, + AiTtsRow, + UpsertBlockInput, + UpsertParentInput, +} from './ai-tts.types' + +const mapParent = (row: typeof aiTts.$inferSelect): AiTtsRow => ({ + id: toEntityId(row.id) as EntityId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + refId: toEntityId(row.refId) as EntityId, + lang: row.lang, + isTranslation: row.isTranslation, + sourceLang: row.sourceLang, + model: row.model, + voice: row.voice, + speed: row.speed, + format: row.format, + blockOrder: row.blockOrder, + charCount: row.charCount, + totalDurationMs: row.totalDurationMs, + sourceModifiedAt: row.sourceModifiedAt, +}) + +const mapBlock = (row: typeof aiTtsBlocks.$inferSelect): AiTtsBlockRow => ({ + id: toEntityId(row.id) as EntityId, + createdAt: row.createdAt, + ttsId: toEntityId(row.ttsId) as EntityId, + blockId: row.blockId, + fingerprint: row.fingerprint, + chunkIndex: row.chunkIndex, + text: row.text, + url: row.url, + storageBackend: row.storageBackend as AiTtsBlockRow['storageBackend'], + storageKey: row.storageKey, + byteSize: row.byteSize, + durationMs: row.durationMs, +}) + +@Injectable() +export class AiTtsRepository extends BaseRepository { + constructor( + @Inject(PG_DB_TOKEN) db: AppDatabase, + private readonly snowflake: SnowflakeService, + ) { + super(db) + } + + async findByRefAndLang( + refId: EntityId | string, + lang: string, + ): Promise { + const [row] = await this.db + .select() + .from(aiTts) + .where(and(eq(aiTts.refId, parseEntityId(refId)), eq(aiTts.lang, lang))) + .limit(1) + return row ? mapParent(row) : null + } + + async findAllByRef(refId: EntityId | string): Promise { + const rows = await this.db + .select() + .from(aiTts) + .where(eq(aiTts.refId, parseEntityId(refId))) + .orderBy(aiTts.lang) + return rows.map(mapParent) + } + + async findBlocks(ttsId: EntityId | string): Promise { + const rows = await this.db + .select() + .from(aiTtsBlocks) + .where(eq(aiTtsBlocks.ttsId, parseEntityId(ttsId))) + .orderBy(aiTtsBlocks.blockId, aiTtsBlocks.chunkIndex) + return rows.map(mapBlock) + } + + async upsertParent(input: UpsertParentInput): Promise { + const [row] = await this.db + .insert(aiTts) + .values({ + id: this.snowflake.nextId(), + refId: parseEntityId(input.refId), + lang: input.lang, + isTranslation: input.isTranslation, + sourceLang: input.sourceLang, + model: input.model, + voice: input.voice, + speed: input.speed, + format: input.format, + blockOrder: input.blockOrder, + charCount: input.charCount, + sourceModifiedAt: input.sourceModifiedAt, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [aiTts.refId, aiTts.lang], + set: { + isTranslation: input.isTranslation, + sourceLang: input.sourceLang, + model: input.model, + voice: input.voice, + speed: input.speed, + format: input.format, + blockOrder: input.blockOrder, + charCount: input.charCount, + sourceModifiedAt: input.sourceModifiedAt, + updatedAt: new Date(), + }, + }) + .returning() + return mapParent(row) + } + + async upsertBlock(input: UpsertBlockInput): Promise { + const [row] = await this.db + .insert(aiTtsBlocks) + .values({ + id: this.snowflake.nextId(), + ttsId: parseEntityId(input.ttsId), + blockId: input.blockId, + chunkIndex: input.chunkIndex, + fingerprint: input.fingerprint, + text: input.text, + url: input.url, + storageBackend: input.storageBackend, + storageKey: input.storageKey, + byteSize: input.byteSize ?? null, + durationMs: input.durationMs ?? null, + }) + .onConflictDoUpdate({ + target: [ + aiTtsBlocks.ttsId, + aiTtsBlocks.blockId, + aiTtsBlocks.chunkIndex, + ], + set: { + fingerprint: input.fingerprint, + text: input.text, + url: input.url, + storageBackend: input.storageBackend, + storageKey: input.storageKey, + byteSize: input.byteSize ?? null, + durationMs: input.durationMs ?? null, + }, + }) + .returning() + return mapBlock(row) + } + + async deleteBlocksByIds(ids: string[]): Promise { + if (!ids.length) return + await this.db.delete(aiTtsBlocks).where( + inArray( + aiTtsBlocks.id, + ids.map((id) => parseEntityId(id)), + ), + ) + } + + async deleteById(id: EntityId | string): Promise { + const ttsId = parseEntityId(id) + return this.db.transaction(async (tx) => { + const [parentRow] = await tx + .select({ id: aiTts.id }) + .from(aiTts) + .where(eq(aiTts.id, ttsId)) + .limit(1) + .for('update') + if (!parentRow) return [] + const blocks = await tx + .delete(aiTtsBlocks) + .where(eq(aiTtsBlocks.ttsId, ttsId)) + .returning() + await tx.delete(aiTts).where(eq(aiTts.id, ttsId)) + return blocks.map(mapBlock) + }) + } + + async deleteByRefId(refId: EntityId | string): Promise { + const refBig = parseEntityId(refId) + return this.db.transaction(async (tx) => { + const parents = await tx + .select({ id: aiTts.id }) + .from(aiTts) + .where(eq(aiTts.refId, refBig)) + .for('update') + if (!parents.length) return [] + const blocks = await tx + .delete(aiTtsBlocks) + .where( + inArray( + aiTtsBlocks.ttsId, + parents.map((p) => p.id), + ), + ) + .returning() + await tx.delete(aiTts).where(eq(aiTts.refId, refBig)) + return blocks.map(mapBlock) + }) + } + + async listPaginated(params: { + page?: number + size?: number + }): Promise> { + const page = Math.max(1, params.page ?? 1) + const size = Math.min(100, Math.max(1, params.size ?? 20)) + const offset = (page - 1) * size + + const [rows, [{ count }]] = await Promise.all([ + this.db + .select() + .from(aiTts) + .orderBy(desc(aiTts.createdAt)) + .limit(size) + .offset(offset), + this.db.select({ count: sql`count(*)::int` }).from(aiTts), + ]) + + return { + data: rows.map(mapParent), + pagination: this.paginationOf(Number(count ?? 0), page, size), + } + } + + async listByRefIds(refIds: Array): Promise { + if (!refIds.length) return [] + const rows = await this.db + .select() + .from(aiTts) + .where( + inArray( + aiTts.refId, + refIds.map((id) => parseEntityId(id)), + ), + ) + .orderBy(aiTts.lang) + return rows.map(mapParent) + } + + async findBlocksByTtsIds( + ttsIds: Array, + ): Promise { + if (!ttsIds.length) return [] + const rows = await this.db + .select() + .from(aiTtsBlocks) + .where( + inArray( + aiTtsBlocks.ttsId, + ttsIds.map((id) => parseEntityId(id)), + ), + ) + .orderBy(aiTtsBlocks.blockId, aiTtsBlocks.chunkIndex) + return rows.map(mapBlock) + } + + async findDistinctRefIds( + refIds?: Array, + ): Promise { + const where = refIds?.length + ? inArray( + aiTts.refId, + refIds.map((id) => parseEntityId(id)), + ) + : undefined + const rows = await this.db + .selectDistinct({ refId: aiTts.refId }) + .from(aiTts) + .where(where) + return rows.map((r) => toEntityId(r.refId) as EntityId) + } + + async groupedByRef( + page = 1, + size = 20, + refIds?: Array, + ): Promise< + PaginationResult<{ refId: EntityId; latestCreated: Date; count: number }> + > { + page = Math.max(1, page) + size = Math.min(100, Math.max(1, size)) + const offset = (page - 1) * size + const where = refIds?.length + ? inArray( + aiTts.refId, + refIds.map((id) => parseEntityId(id)), + ) + : undefined + const [rows, [{ count }]] = await Promise.all([ + this.db + .select({ + refId: aiTts.refId, + latestCreated: sql`max(${aiTts.createdAt})`, + count: sql`count(*)::int`, + }) + .from(aiTts) + .where(where) + .groupBy(aiTts.refId) + .orderBy(sql`max(${aiTts.createdAt}) desc`) + .limit(size) + .offset(offset), + this.db + .select({ + count: sql`count(distinct ${aiTts.refId})::int`, + }) + .from(aiTts) + .where(where), + ]) + return { + data: rows.map((row) => ({ + refId: toEntityId(row.refId) as EntityId, + latestCreated: row.latestCreated, + count: Number(row.count ?? 0), + })), + pagination: this.paginationOf(Number(count ?? 0), page, size), + } + } + + async findMeta( + refId: EntityId | string, + lang: string, + ): Promise { + const [row] = await this.db + .select({ + id: aiTts.id, + updatedAt: aiTts.updatedAt, + sourceModifiedAt: aiTts.sourceModifiedAt, + blockCount: sql`jsonb_array_length(${aiTts.blockOrder})`, + }) + .from(aiTts) + .where(and(eq(aiTts.refId, parseEntityId(refId)), eq(aiTts.lang, lang))) + .limit(1) + if (!row) return null + return { + id: toEntityId(row.id) as EntityId, + updatedAt: row.updatedAt, + sourceModifiedAt: row.sourceModifiedAt, + blockCount: Number(row.blockCount ?? 0), + } + } +} diff --git a/apps/core/src/modules/ai/ai-tts/ai-tts.schema.ts b/apps/core/src/modules/ai/ai-tts/ai-tts.schema.ts new file mode 100644 index 00000000000..c5f7f41e7de --- /dev/null +++ b/apps/core/src/modules/ai/ai-tts/ai-tts.schema.ts @@ -0,0 +1,38 @@ +import { createZodDto } from 'nestjs-zod' +import { z } from 'zod' + +import { normalizeLanguageCode } from '~/utils/lang.util' + +const MAX_TASK_LANGS = 8 + +const zResolvableLang = z + .string() + .refine((lang) => normalizeLanguageCode(lang) !== undefined, { + message: 'unresolvable language code', + }) + +export const CreateTtsTaskSchema = z.object({ + refId: z.string(), + langs: z.array(zResolvableLang).max(MAX_TASK_LANGS).optional(), + force: z.boolean().optional(), +}) +export class CreateTtsTaskDto extends createZodDto(CreateTtsTaskSchema) {} + +export const GetTtsQuerySchema = z.object({ + lang: z.string().optional(), + password: z.string().optional(), +}) +export class GetTtsQueryDto extends createZodDto(GetTtsQuerySchema) {} + +export const GetTtsGroupedQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + size: z.coerce.number().int().min(1).max(50).default(20), + search: z.string().optional(), +}) +export class GetTtsGroupedQueryDto extends createZodDto( + GetTtsGroupedQuerySchema, +) {} + +export type CreateTtsTaskInput = z.infer +export type GetTtsQueryInput = z.infer +export type GetTtsGroupedQueryInput = z.infer diff --git a/apps/core/src/modules/ai/ai-tts/ai-tts.service.ts b/apps/core/src/modules/ai/ai-tts/ai-tts.service.ts new file mode 100644 index 00000000000..b5e18ba509f --- /dev/null +++ b/apps/core/src/modules/ai/ai-tts/ai-tts.service.ts @@ -0,0 +1,486 @@ +import { Injectable, Logger, type OnModuleInit } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import pLimit from 'p-limit' + +import { AppErrorCode, createAppException } from '~/common/errors' +import { BusinessEvents } from '~/constants/business-event.constant' +import { DatabaseService } from '~/processors/database/database.service' +import { LexicalService } from '~/processors/helper/helper.lexical.service' +import { RedisService } from '~/processors/redis/redis.service' +import { + type TaskExecuteContext, + TaskQueueProcessor, + TaskStatus, +} from '~/processors/task-queue' +import { throwIfAborted } from '~/utils/abort.util' + +import { ConfigsService } from '../../configs/configs.service' +import { FileService } from '../../file/file.service' +import { parseLanguageCode } from '../ai-language.util' +import { AITaskType, type TtsTaskPayload } from '../ai-task/ai-task.types' +import { AiTranslationRepository } from '../ai-translation/ai-translation.repository' +import { readArticleMetaLang } from '../ai-translation/article-content.util' +import { AiTtsRepository } from './ai-tts.repository' +import type { + ExistingBlockRow, + PlannedChunk, + TtsLanguageResult, + TtsProviderConfig, + TtsSourceDocument, + TtsStoredObject, + TtsVoiceConfig, +} from './ai-tts.types' +import { planChunks, planTts } from './tts-block-plan' +import { withTtsLangLock } from './tts-lang-lock' +import { + buildTtsObjectKey, + computeTtsObjectFingerprint, +} from './tts-object-key' +import { TtsRuntimeAdapter } from './tts-runtime.adapter' +import { resolveTtsSourceContent } from './tts-source-content' + +// Caps in-flight synthesis across every TTS task in this process; ten tasks at +// concurrency 3 would otherwise open sixty provider connections at once. +const GLOBAL_SPEECH_LIMIT = pLimit(8) + +const MAX_LANGS_PER_TASK = 8 +const AUDIO_FORMAT = 'mp3' +const AUDIO_CONTENT_TYPE = 'audio/mpeg' + +interface LanguageRunInput { + concurrency: number + configuredVoice: TtsVoiceConfig + context: TaskExecuteContext + document: TtsSourceDocument + force: boolean + lang: string + maxCharsPerChunk: number + maxCharsPerRun: number + objectKeyPrefix?: string + provider: TtsProviderConfig + refId: string + reportProgress: (done: number, total: number) => Promise + sourceLang: string +} + +function chunkKey(blockId: string, chunkIndex: number): string { + return `${blockId}#${chunkIndex}` +} + +function sameInstant(a?: Date | null, b?: Date | null): boolean { + return (a?.getTime() ?? null) === (b?.getTime() ?? null) +} + +@Injectable() +export class AiTtsService implements OnModuleInit { + private readonly logger = new Logger(AiTtsService.name) + + constructor( + private readonly configService: ConfigsService, + private readonly fileService: FileService, + private readonly taskProcessor: TaskQueueProcessor, + private readonly repository: AiTtsRepository, + private readonly databaseService: DatabaseService, + private readonly lexicalService: LexicalService, + private readonly translationRepository: AiTranslationRepository, + private readonly redisService: RedisService, + ) {} + + onModuleInit() { + this.taskProcessor.registerHandler({ + type: AITaskType.Tts, + execute: async (payload: TtsTaskPayload, context: TaskExecuteContext) => { + await this.runTask(payload, context) + }, + }) + + this.logger.log('AI TTS task handler registered') + } + + private async runTask(payload: TtsTaskPayload, context: TaskExecuteContext) { + throwIfAborted(context.signal) + + const config = await this.configService.get('ttsOptions') + if (!config.enable) { + throw createAppException(AppErrorCode.TTS_DISABLED) + } + const { apiKey, model, voice } = config + if (!apiKey || !model || !voice) { + throw createAppException(AppErrorCode.TTS_PROVIDER_NOT_CONFIGURED) + } + + const document = await this.loadDocument(payload.refId) + const sourceLang = parseLanguageCode(readArticleMetaLang(document)) + const targets = payload.langs?.length + ? [...new Set(payload.langs.map((lang) => parseLanguageCode(lang)))] + : [sourceLang] + + if (targets.length > MAX_LANGS_PER_TASK) { + throw createAppException(AppErrorCode.AI_INVALID_PARAMETER, { + message: `a tts task carries at most ${MAX_LANGS_PER_TASK} languages`, + }) + } + + const { prefix } = await this.configService.get('imageStorageOptions') + + const perLang: TtsLanguageResult[] = [] + const skipped: Array<{ lang: string; reason: string }> = [] + const failed: Array<{ lang: string; reason: string }> = [] + + await context.updateProgress(0, 'Starting narration', 0, targets.length) + + const redis = this.redisService.getClient() + const shared = { + concurrency: config.concurrency, + configuredVoice: { model, voice, speed: config.speed }, + context, + document, + force: Boolean(payload.force), + maxCharsPerChunk: config.maxCharsPerChunk, + maxCharsPerRun: config.maxCharsPerRun, + objectKeyPrefix: prefix, + provider: { + provider: config.provider, + apiKey, + endpoint: config.endpoint || undefined, + }, + refId: payload.refId, + sourceLang, + } + + for (const [index, lang] of targets.entries()) { + throwIfAborted(context.signal) + + try { + const result = await withTtsLangLock( + redis, + payload.refId, + lang, + () => + this.runLanguage({ + ...shared, + lang, + reportProgress: (done, total) => + context.updateProgress( + Math.round(((index + done / total) / targets.length) * 100), + `Generated ${done}/${total} (${lang})`, + done, + total, + ), + }), + (error, phase) => + this.logger.warn( + `tts lock ${phase} failed for ${payload.refId}:${lang}: ${error.message}`, + ), + ) + + if (!result) { + skipped.push({ lang, reason: 'another run holds the lock' }) + await context.appendLog( + 'warn', + `narration for ${lang} skipped: another run holds the lock`, + ) + continue + } + perLang.push(result) + } catch (error) { + if ((error as Error).name === 'AbortError') throw error + const reason = (error as Error).message + failed.push({ lang, reason }) + await context.appendLog( + 'error', + `narration for ${lang} failed: ${reason}`, + ) + } + } + + await context.updateProgress( + 100, + `Narrated ${perLang.length}/${targets.length}`, + targets.length, + targets.length, + ) + await context.setResult({ perLang, skipped, failed }) + + // A requeued language committed chunks but never published its block_order, + // so reporting it green would hide unfinished work from the operator. + const requeued = perLang.filter((result) => result.requeued).length + const attempted = targets.length - skipped.length + if (failed.length > 0 && failed.length === attempted) { + context.setStatus(TaskStatus.Failed) + } else if (failed.length > 0 || requeued > 0) { + context.setStatus(TaskStatus.PartialFailed) + } + } + + private async runLanguage( + input: LanguageRunInput, + ): Promise { + const { context, document, force, lang, refId, sourceLang } = input + + const isTranslation = lang !== sourceLang + const { content, sourceModifiedAt } = await resolveTtsSourceContent({ + document, + findTranslation: (id, code) => + this.translationRepository.findByRefAndLang(id, code), + isTranslation, + lang, + refId, + sourceLang, + }) + + const { chunks, blocksWithoutId } = planChunks( + this.lexicalService.extractRootBlockNodes(content), + input.maxCharsPerChunk, + ) + if (blocksWithoutId.length > 0) { + this.logger.warn( + `speakable blocks without an id at index ${blocksWithoutId.join(', ')}: ref=${refId} lang=${lang}`, + ) + } + + const parent = await this.repository.findByRefAndLang(refId, lang) + const existing = parent ? await this.repository.findBlocks(parent.id) : [] + if (!chunks.length && !parent) { + throw createAppException(AppErrorCode.TTS_SOURCE_NOT_LEXICAL, { lang }) + } + + const voice: TtsVoiceConfig = + parent && !force + ? { model: parent.model, voice: parent.voice, speed: parent.speed } + : input.configuredVoice + const objectKeyFor = (chunk: PlannedChunk) => + buildTtsObjectKey({ + prefix: input.objectKeyPrefix, + refId, + lang, + blockId: chunk.blockId, + chunkIndex: chunk.chunkIndex, + fingerprint: computeTtsObjectFingerprint(chunk.fingerprint, voice), + }) + + const plan = planTts({ chunks, existing, force, objectKeyFor }) + const spendChars = plan.toGenerate.reduce( + (sum, chunk) => sum + chunk.text.length, + 0, + ) + if (spendChars > input.maxCharsPerRun) { + throw createAppException(AppErrorCode.TTS_BUDGET_EXCEEDED, { + charCount: spendChars, + limit: input.maxCharsPerRun, + }) + } + + const parentBase = { + ...voice, + refId, + lang, + isTranslation, + sourceLang: isTranslation ? sourceLang : null, + format: AUDIO_FORMAT, + } + + // ai_tts_blocks carries an FK to ai_tts, so a first run needs the parent row + // before any chunk can commit; block_order stays empty until finalize so an + // unfinished language is simply not published yet. + const ttsId = + parent?.id ?? + ( + await this.repository.upsertParent({ + ...parentBase, + blockOrder: [], + charCount: 0, + sourceModifiedAt: null, + }) + ).id + + const { displaced, generated } = await this.synthesize(input, { + existing, + objectKeyFor, + toGenerate: plan.toGenerate, + ttsId, + voice, + }) + + const summary = { + lang, + ttsId, + total: chunks.length, + generated, + reused: plan.toReuse.length, + deleted: 0, + charCount: plan.charCount, + } + + const current = await this.loadDocument(refId) + if (!sameInstant(current.modifiedAt, document.modifiedAt)) { + this.logger.warn( + `tts source changed mid-run, finalize skipped: ref=${refId} lang=${lang}`, + ) + await context.appendLog( + 'warn', + `source changed mid-run; ${lang} narration is not published yet`, + ) + return { ...summary, requeued: true } + } + + await this.repository.upsertParent({ + ...parentBase, + blockOrder: plan.blockOrder, + charCount: plan.charCount, + sourceModifiedAt, + }) + await this.repository.deleteBlocksByIds( + plan.toDelete.map((row) => row.rowId), + ) + await this.deleteObjects([...plan.toDelete, ...displaced]) + + return { ...summary, deleted: plan.toDelete.length } + } + + private async synthesize( + input: LanguageRunInput, + work: { + existing: ExistingBlockRow[] + objectKeyFor: (chunk: PlannedChunk) => string + toGenerate: PlannedChunk[] + ttsId: string + voice: TtsVoiceConfig + }, + ): Promise<{ displaced: TtsStoredObject[]; generated: number }> { + const { context } = input + const { existing, objectKeyFor, toGenerate, ttsId, voice } = work + + const previousByKey = new Map( + existing.map((row) => [chunkKey(row.blockId, row.chunkIndex), row]), + ) + const runtime = new TtsRuntimeAdapter({ + ...input.provider, + model: voice.model, + }) + const limit = pLimit(input.concurrency) + const displaced: TtsStoredObject[] = [] + const total = toGenerate.length + let done = 0 + + // p-limit cancels nothing on the first rejection, so every limited task has + // to settle before the caller releases the language lock — otherwise a + // straggler keeps writing rows after another holder has acquired it. + const settled = await Promise.allSettled( + toGenerate.map((chunk) => + limit(async () => { + throwIfAborted(context.signal) + + const { buffer } = await GLOBAL_SPEECH_LIMIT(() => + runtime.generateSpeech({ + input: chunk.text, + voice: voice.voice, + speed: voice.speed, + signal: context.signal, + }), + ) + + const uploaded = await this.uploadChunk(buffer, objectKeyFor(chunk)) + + // planTts leaves a regenerated chunk out of toDelete because the upsert + // replaces its row, so its old object is only reachable from here. + const previous = previousByKey.get( + chunkKey(chunk.blockId, chunk.chunkIndex), + ) + if (previous && previous.storageKey !== uploaded.storageKey) { + displaced.push({ + storageBackend: previous.storageBackend, + storageKey: previous.storageKey, + }) + } + + await this.repository.upsertBlock({ + ttsId, + blockId: chunk.blockId, + chunkIndex: chunk.chunkIndex, + fingerprint: chunk.fingerprint, + text: chunk.text, + url: uploaded.url, + storageBackend: uploaded.storageBackend, + storageKey: uploaded.storageKey, + byteSize: buffer.length, + }) + + done += 1 + await input.reportProgress(done, total) + }), + ), + ) + + const rejections = settled + .filter((result) => result.status === 'rejected') + .map((result) => result.reason as Error) + const aborted = rejections.find((error) => error?.name === 'AbortError') + if (aborted) throw aborted + if (rejections.length > 0) throw rejections[0] + + return { displaced, generated: done } + } + + private async uploadChunk(buffer: Buffer, objectKey: string) { + try { + return await this.fileService.uploadBuffer(buffer, { + type: 'audio', + contentType: AUDIO_CONTENT_TYPE, + objectKey, + }) + } catch (error) { + // Object keys are content-addressed, so a resumed run rewrites byte-identical + // audio; the local backend rejects that as FILE_EXISTS instead of overwriting. + if ((error as { code?: string })?.code !== AppErrorCode.FILE_EXISTS) { + throw error + } + return { + url: await this.fileService.resolveFileUrl('audio', objectKey), + name: objectKey.split('/').pop()!, + storageBackend: 'local' as const, + storageKey: objectKey, + } + } + } + + private async loadDocument(refId: string): Promise { + const article = await this.databaseService.findGlobalById(refId) + if (!article?.document) { + throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS) + } + return article.document as TtsSourceDocument + } + + private async deleteObjects(objects: TtsStoredObject[]): Promise { + for (const object of objects) { + try { + await this.fileService.deleteObject( + object.storageBackend, + object.storageKey, + ) + } catch (error) { + this.logger.warn( + `failed to delete tts object ${object.storageKey}: ${(error as Error).message}`, + ) + } + } + } + + @OnEvent(BusinessEvents.POST_DELETE) + @OnEvent(BusinessEvents.NOTE_DELETE) + @OnEvent(BusinessEvents.PAGE_DELETE) + async handleDeleteArticle(event: { id: string }): Promise { + await this.handleArticleDeleted(event.id) + } + + async handleArticleDeleted(refId: string): Promise { + const removed = await this.repository.deleteByRefId(refId) + await this.deleteObjects(removed) + } + + async deleteById(id: string): Promise { + const removed = await this.repository.deleteById(id) + await this.deleteObjects(removed) + } +} diff --git a/apps/core/src/modules/ai/ai-tts/ai-tts.types.ts b/apps/core/src/modules/ai/ai-tts/ai-tts.types.ts new file mode 100644 index 00000000000..e8ca7f1d8fd --- /dev/null +++ b/apps/core/src/modules/ai/ai-tts/ai-tts.types.ts @@ -0,0 +1,147 @@ +export interface PlannedChunk { + blockId: string + chunkIndex: number + type: string + text: string + fingerprint: string +} + +export interface ExistingBlockRow { + id: string + blockId: string + chunkIndex: number + fingerprint: string + storageBackend: 's3' | 'local' + storageKey: string +} + +export interface TtsPlan { + toGenerate: PlannedChunk[] + toReuse: Array<{ rowId: string; blockId: string; chunkIndex: number }> + toDelete: Array<{ + rowId: string + storageBackend: 's3' | 'local' + storageKey: string + }> + blockOrder: string[] + charCount: number +} + +export interface PlanTtsInput { + chunks: PlannedChunk[] + existing: ExistingBlockRow[] + force: boolean + /** + * The object key this run would write for a chunk. It folds in the run's + * voice triple, so comparing it against `row.storageKey` is what stops a + * `force` run that died partway from leaving mixed-voice audio behind. + */ + objectKeyFor: (chunk: PlannedChunk) => string +} + +export interface AiTtsRow { + id: string + createdAt: Date + updatedAt: Date | null + refId: string + lang: string + isTranslation: boolean + sourceLang: string | null + model: string + voice: string + speed: number + format: string + blockOrder: string[] + charCount: number + totalDurationMs: number | null + sourceModifiedAt: Date | null +} + +export interface AiTtsBlockRow { + id: string + createdAt: Date + ttsId: string + blockId: string + fingerprint: string + chunkIndex: number + text: string + url: string + storageBackend: 's3' | 'local' + storageKey: string + byteSize: number | null + durationMs: number | null +} + +export interface AiTtsMeta { + id: string + updatedAt: Date | null + blockCount: number + sourceModifiedAt: Date | null +} + +export interface UpsertParentInput { + refId: string + lang: string + isTranslation: boolean + sourceLang: string | null + model: string + voice: string + speed: number + format: string + blockOrder: string[] + charCount: number + sourceModifiedAt: Date | null +} + +export interface TtsSourceDocument { + title: string + text: string + subtitle?: string | null + summary?: string | null + tags?: string[] + contentFormat?: string | null + content?: string | null + meta?: Record | null + modifiedAt?: Date | null +} + +export interface TtsStoredObject { + storageBackend: 's3' | 'local' + storageKey: string +} + +export interface TtsVoiceConfig { + model: string + voice: string + speed: number +} + +export interface TtsProviderConfig { + provider: string + apiKey: string + endpoint?: string +} + +export interface TtsLanguageResult { + lang: string + ttsId: string + total: number + generated: number + reused: number + deleted: number + charCount: number + requeued?: boolean +} + +export interface UpsertBlockInput { + ttsId: string + blockId: string + chunkIndex: number + fingerprint: string + text: string + url: string + storageBackend: 's3' | 'local' + storageKey: string + byteSize?: number | null + durationMs?: number | null +} diff --git a/apps/core/src/modules/ai/ai-tts/ai-tts.views.ts b/apps/core/src/modules/ai/ai-tts/ai-tts.views.ts new file mode 100644 index 00000000000..b5d3fbd3bfb --- /dev/null +++ b/apps/core/src/modules/ai/ai-tts/ai-tts.views.ts @@ -0,0 +1,43 @@ +import { z } from 'zod' + +const SegmentSchema = z.object({ + blockId: z.string(), + chunkIndex: z.number(), + text: z.string(), + url: z.string(), +}) + +export const AiTtsViews = { + public: z.object({ + lang: z.string(), + model: z.string(), + voice: z.string(), + blockOrder: z.array(z.string()), + segments: z.array(SegmentSchema), + }), + detail: z.object({ + id: z.string(), + refId: z.string(), + lang: z.string(), + isTranslation: z.boolean(), + model: z.string(), + voice: z.string(), + speed: z.number(), + blockOrder: z.array(z.string()), + charCount: z.number(), + updatedAt: z.date().nullish(), + segments: z.array(SegmentSchema), + }), + listItem: z.object({ + id: z.string(), + refId: z.string(), + lang: z.string(), + blockCount: z.number(), + charCount: z.number(), + updatedAt: z.date().nullish(), + }), +} as const + +export type AiTtsPublicView = z.infer +export type AiTtsDetailView = z.infer +export type AiTtsListItemView = z.infer diff --git a/apps/core/src/modules/ai/ai-tts/tts-block-plan.ts b/apps/core/src/modules/ai/ai-tts/tts-block-plan.ts new file mode 100644 index 00000000000..08ffdffd314 --- /dev/null +++ b/apps/core/src/modules/ai/ai-tts/tts-block-plan.ts @@ -0,0 +1,180 @@ +import { md5 } from '~/utils/tool.util' + +import type { + ExistingBlockRow, + PlannedChunk, + PlanTtsInput, + TtsPlan, +} from './ai-tts.types' + +export const SPEAKABLE_BLOCK_TYPES: ReadonlySet = new Set([ + 'paragraph', + 'heading', + 'quote', + 'rich-quote', + 'list', +]) + +const SENTENCE_SEPARATOR = '。' + +function collectInlineText(node: any): string { + if (!node || typeof node !== 'object') return '' + if (node.type === 'text') return String(node.text ?? '') + if (node.type === 'linebreak') return ' ' + if (node.type === 'list' && Array.isArray(node.children)) { + return node.children + .map((item: any) => collectInlineText(item).trim()) + .filter(Boolean) + .join(SENTENCE_SEPARATOR) + } + if (Array.isArray(node.children)) { + return node.children.map((child: any) => collectInlineText(child)).join('') + } + return '' +} + +export function extractSpeakableText(node: any): string { + return collectInlineText(node).replaceAll(/\s+/g, ' ').trim() +} + +const SENTENCE_SPLIT_RE = + /[^!.?。!?]*[。!?]|[^!.?。!?]*[!.?](?:\s|$)|[^!.?。!?]+$/g + +export function splitIntoChunks(text: string, maxChars: number): string[] { + if (!Number.isFinite(maxChars) || maxChars <= 0) { + throw new RangeError( + `splitIntoChunks: maxChars must be a positive finite number, got ${maxChars}`, + ) + } + if (!text) return [] + if (text.length <= maxChars) return [text] + + const sentences = text.match(SENTENCE_SPLIT_RE) ?? [text] + const chunks: string[] = [] + let current = '' + + const flush = () => { + if (current) { + chunks.push(current) + current = '' + } + } + + for (const sentence of sentences) { + if (sentence.length > maxChars) { + flush() + for (let i = 0; i < sentence.length; i += maxChars) { + chunks.push(sentence.slice(i, i + maxChars)) + } + continue + } + if (current.length + sentence.length > maxChars) flush() + current += sentence + } + flush() + + return chunks +} + +export function computeSpeechFingerprint( + type: string, + chunkText: string, +): string { + return md5(`${type}:${chunkText}`) +} + +export interface RootBlockNode { + id: string | null + type: string + node: any + index: number +} + +export function planChunks( + blocks: RootBlockNode[], + maxChars: number, +): { chunks: PlannedChunk[]; blocksWithoutId: number[] } { + const chunks: PlannedChunk[] = [] + const blocksWithoutId: number[] = [] + + for (const block of blocks) { + if (!SPEAKABLE_BLOCK_TYPES.has(block.type)) continue + const text = extractSpeakableText(block.node) + if (!text) continue + + if (!block.id) blocksWithoutId.push(block.index) + const blockId = block.id ?? `idx:${block.index}` + + for (const [chunkIndex, chunkText] of splitIntoChunks( + text, + maxChars, + ).entries()) { + chunks.push({ + blockId, + chunkIndex, + type: block.type, + text: chunkText, + fingerprint: computeSpeechFingerprint(block.type, chunkText), + }) + } + } + + return { chunks, blocksWithoutId } +} + +function rowKey(blockId: string, chunkIndex: number): string { + return `${blockId}#${chunkIndex}` +} + +export function planTts(input: PlanTtsInput): TtsPlan { + const { chunks, existing, force, objectKeyFor } = input + const existingByKey = new Map( + existing.map((row) => [rowKey(row.blockId, row.chunkIndex), row]), + ) + + const toGenerate: PlannedChunk[] = [] + const toReuse: TtsPlan['toReuse'] = [] + const consumed = new Set() + + for (const chunk of chunks) { + const key = rowKey(chunk.blockId, chunk.chunkIndex) + const row = existingByKey.get(key) + if (row) consumed.add(row.id) + + if ( + !force && + row && + row.fingerprint === chunk.fingerprint && + row.storageKey === objectKeyFor(chunk) + ) { + toReuse.push({ + rowId: row.id, + blockId: chunk.blockId, + chunkIndex: chunk.chunkIndex, + }) + continue + } + toGenerate.push(chunk) + } + + const toDelete = existing + .filter((row) => !consumed.has(row.id)) + .map((row) => ({ + rowId: row.id, + storageBackend: row.storageBackend, + storageKey: row.storageKey, + })) + + const blockOrder: string[] = [] + for (const chunk of chunks) { + if (blockOrder.at(-1) !== chunk.blockId) blockOrder.push(chunk.blockId) + } + + return { + toGenerate, + toReuse, + toDelete, + blockOrder, + charCount: chunks.reduce((sum, chunk) => sum + chunk.text.length, 0), + } +} diff --git a/apps/core/src/modules/ai/ai-tts/tts-lang-lock.ts b/apps/core/src/modules/ai/ai-tts/tts-lang-lock.ts new file mode 100644 index 00000000000..0943efe0656 --- /dev/null +++ b/apps/core/src/modules/ai/ai-tts/tts-lang-lock.ts @@ -0,0 +1,45 @@ +import type IORedis from 'ioredis' + +const LOCK_TTL_SEC = 300 +const LOCK_RENEW_INTERVAL_MS = 120_000 + +// Both scripts compare-and-swap on the token: a bare EXPIRE/DEL would extend or +// release a *different* holder's lock if ours expired between the read and the +// write. +const RENEW_SCRIPT = `if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('expire', KEYS[1], ARGV[2]) else return 0 end` +const RELEASE_SCRIPT = `if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end` + +export function ttsLangLockKey(refId: string, lang: string): string { + return `ai:tts:lock:${refId}:${lang}` +} + +export async function withTtsLangLock( + redis: IORedis, + refId: string, + lang: string, + fn: () => Promise, + onLockError?: (error: Error, phase: 'renew' | 'release') => void, +): Promise { + const key = ttsLangLockKey(refId, lang) + const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` + const acquired = await redis.set(key, token, 'EX', LOCK_TTL_SEC, 'NX') + if (!acquired) return null + + const renew = setInterval(() => { + redis + .eval(RENEW_SCRIPT, 1, key, token, String(LOCK_TTL_SEC)) + .catch((error: Error) => onLockError?.(error, 'renew')) + }, LOCK_RENEW_INTERVAL_MS) + + try { + return await fn() + } finally { + clearInterval(renew) + // A failed release must not replace the body's result or its error: the + // lock still expires on its own TTL, but reporting a published language as + // failed would be a lie. + await redis + .eval(RELEASE_SCRIPT, 1, key, token) + .catch((error: Error) => onLockError?.(error, 'release')) + } +} diff --git a/apps/core/src/modules/ai/ai-tts/tts-object-key.ts b/apps/core/src/modules/ai/ai-tts/tts-object-key.ts new file mode 100644 index 00000000000..005a34587ab --- /dev/null +++ b/apps/core/src/modules/ai/ai-tts/tts-object-key.ts @@ -0,0 +1,36 @@ +import { md5 } from '~/utils/tool.util' + +const SAFE_SEGMENT = /[^\w-]/g + +// The speech fingerprint addresses the *text*, so on its own it would give +// re-voiced audio the key of the audio it replaces — an in-place overwrite +// behind a year-long CDN cache. The stored object is addressed by text AND by +// the voice config that produced it, and `planTts` reuses a row only when its +// storageKey equals the key this run would write, so a row left behind at +// another voice by a crashed `force` run regenerates instead of persisting. +export function computeTtsObjectFingerprint( + speechFingerprint: string, + voice: { model: string; voice: string; speed: number }, +): string { + return md5( + `${speechFingerprint}|${voice.model}|${voice.voice}|${voice.speed}`, + ) +} + +export function buildTtsObjectKey(input: { + prefix?: string + refId: string + lang: string + blockId: string + chunkIndex: number + fingerprint: string +}): string { + const prefix = (input.prefix ?? '').replaceAll(/^\/+|\/+$/g, '') + const blockId = input.blockId + .replaceAll('/', '') + .replaceAll(SAFE_SEGMENT, '-') + const lang = input.lang.replaceAll('/', '').replaceAll(SAFE_SEGMENT, '-') + const name = `${blockId}-${input.chunkIndex}-${input.fingerprint.slice(0, 12)}.mp3` + const path = `tts/${input.refId}/${lang}/${name}` + return prefix ? `${prefix}/${path}` : path +} diff --git a/apps/core/src/modules/ai/ai-tts/tts-runtime.adapter.ts b/apps/core/src/modules/ai/ai-tts/tts-runtime.adapter.ts new file mode 100644 index 00000000000..30ca541630a --- /dev/null +++ b/apps/core/src/modules/ai/ai-tts/tts-runtime.adapter.ts @@ -0,0 +1,137 @@ +import { AppErrorCode, createAppException } from '~/common/errors' +import { sleep } from '~/utils/tool.util' + +export interface TtsGenerateOptions { + input: string + voice: string + speed: number + providerParams?: Record + signal?: AbortSignal +} + +export interface ITtsRuntime { + generateSpeech: ( + opts: TtsGenerateOptions, + ) => Promise<{ buffer: Buffer; mimeType: string }> +} + +export interface TtsRuntimeAdapterConfig { + provider: string + apiKey: string + endpoint?: string + model: string + maxAttempts?: number + retryDelayMs?: number +} + +const PRESET_BASE_URLS: Record = { + openrouter: 'https://openrouter.ai/api/v1', + openai: 'https://api.openai.com/v1', +} + +export function resolveTtsBaseUrl(provider: string, endpoint?: string): string { + const trimmed = endpoint?.trim().replace(/\/+$/, '') + if (trimmed) return trimmed + const preset = PRESET_BASE_URLS[provider] + if (!preset) { + throw createAppException(AppErrorCode.TTS_PROVIDER_NOT_CONFIGURED) + } + return preset +} + +export class TtsRuntimeAdapter implements ITtsRuntime { + private readonly baseUrl: string + private readonly maxAttempts: number + private readonly retryDelayMs: number + + constructor(private readonly config: TtsRuntimeAdapterConfig) { + this.baseUrl = resolveTtsBaseUrl(config.provider, config.endpoint) + this.maxAttempts = config.maxAttempts ?? 3 + this.retryDelayMs = config.retryDelayMs ?? 500 + } + + async generateSpeech( + opts: TtsGenerateOptions, + ): Promise<{ buffer: Buffer; mimeType: string }> { + let lastError: Error | undefined + + for (let attempt = 1; attempt <= this.maxAttempts; attempt++) { + try { + return await this.requestOnce(opts) + } catch (error) { + lastError = error as Error + // an aborted signal is caller cancellation, not a transient failure — fail fast. + if ( + opts.signal?.aborted || + !isRetryable(error) || + attempt === this.maxAttempts + ) + break + await sleep(this.retryDelayMs * 2 ** (attempt - 1)) + } + } + + throw createAppException(AppErrorCode.TTS_GENERATION_FAILED, { + message: lastError?.message, + }) + } + + private async requestOnce(opts: TtsGenerateOptions) { + const response = await fetch(`${this.baseUrl}/audio/speech`, { + method: 'POST', + headers: { + authorization: `Bearer ${this.config.apiKey}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model: this.config.model, + input: opts.input, + voice: opts.voice, + speed: opts.speed, + // response_format defaults to pcm on OpenRouter; mp3 must be explicit. + response_format: 'mp3', + ...opts.providerParams, + }), + signal: opts.signal, + }) + + if (!response.ok) { + throw new HttpStatusError(response.status, await safeText(response)) + } + + // some providers respond 200 with a JSON error body instead of audio. + const mimeType = response.headers.get('content-type') ?? '' + if (!mimeType.startsWith('audio/')) { + throw new HttpStatusError(response.status, await safeText(response)) + } + + return { + buffer: Buffer.from(await response.arrayBuffer()), + mimeType, + } + } +} + +class HttpStatusError extends Error { + constructor( + readonly status: number, + body: string, + ) { + super(`tts request failed (${status}): ${body.slice(0, 300)}`) + } +} + +function isRetryable(error: unknown): boolean { + if (error instanceof HttpStatusError) { + return error.status >= 500 || error.status === 429 + } + return true +} + +async function safeText(response: Response): Promise { + try { + return await response.text() + } catch { + return '' + } +} diff --git a/apps/core/src/modules/ai/ai-tts/tts-source-content.ts b/apps/core/src/modules/ai/ai-tts/tts-source-content.ts new file mode 100644 index 00000000000..6e807e839c0 --- /dev/null +++ b/apps/core/src/modules/ai/ai-tts/tts-source-content.ts @@ -0,0 +1,70 @@ +import { AppErrorCode, createAppException } from '~/common/errors' +import { computeContentHash } from '~/utils/content.util' + +import { parseLanguageCode } from '../ai-language.util' +import type { + AiTranslationRow, + ArticleDocument, +} from '../ai-translation/ai-translation.types' +import { toArticleContent } from '../ai-translation/article-content.util' +import type { TtsSourceDocument } from './ai-tts.types' + +export interface TtsSourceContent { + content: string + sourceModifiedAt: Date | null +} + +export async function resolveTtsSourceContent(params: { + document: TtsSourceDocument + findTranslation: ( + refId: string, + lang: string, + ) => Promise + isTranslation: boolean + lang: string + refId: string + sourceLang: string +}): Promise { + const { document, findTranslation, isTranslation, lang, refId, sourceLang } = + params + + if (!isTranslation) { + if (document.contentFormat !== 'lexical' || !document.content) { + throw createAppException(AppErrorCode.TTS_SOURCE_NOT_LEXICAL, { lang }) + } + return { + content: document.content, + sourceModifiedAt: document.modifiedAt ?? null, + } + } + + const row = await findTranslation(refId, lang) + if ( + !row || + row.contentFormat !== 'lexical' || + !row.content || + parseLanguageCode(row.sourceLang) !== sourceLang + ) { + throw createAppException(AppErrorCode.TTS_SOURCE_NOT_LEXICAL, { lang }) + } + + // Re-derived with `row.sourceLang` because that is the exact string the + // writer hashed with and then persisted (ai-translation.service.ts) — the + // canonicalized code would never match a row whose sourceLang has a region + // subtag, and that language would fail on every run. + const currentHash = computeContentHash( + toArticleContent(document as unknown as ArticleDocument), + row.sourceLang, + ) + if (row.hash !== currentHash) { + throw createAppException(AppErrorCode.TTS_SOURCE_NOT_LEXICAL, { lang }) + } + + // The narration is only as current as the translation it was voiced from, so + // the row carries the translation's vintage rather than the article's — + // otherwise a months-old translation reads back as fresh. + return { + content: row.content, + sourceModifiedAt: row.sourceModifiedAt ?? row.createdAt ?? null, + } +} diff --git a/apps/core/src/modules/ai/ai.module.ts b/apps/core/src/modules/ai/ai.module.ts index c452edb4e03..9c5b00c36f9 100644 --- a/apps/core/src/modules/ai/ai.module.ts +++ b/apps/core/src/modules/ai/ai.module.ts @@ -1,6 +1,7 @@ import { forwardRef, Module } from '@nestjs/common' import { DraftModule } from '../draft/draft.module' +import { MembershipModule } from '../membership/membership.module' import { NoteModule } from '../note/note.module' import { TopicModule } from '../topic/topic.module' import { AiController } from './ai.controller' @@ -38,6 +39,10 @@ import { LEXICAL_TRANSLATION_STRATEGY, MARKDOWN_TRANSLATION_STRATEGY, } from './ai-translation/translation-strategy.interface' +import { AiTtsController } from './ai-tts/ai-tts.controller' +import { AiTtsRepository } from './ai-tts/ai-tts.repository' +import { AiTtsService } from './ai-tts/ai-tts.service' +import { AiTtsQueryService } from './ai-tts/ai-tts-query.service' import { AiSlugBackfillService } from './ai-writer/ai-slug-backfill.service' import { AiWriterController } from './ai-writer/ai-writer.controller' import { AiWriterService } from './ai-writer/ai-writer.service' @@ -47,6 +52,7 @@ import { AiWriterService } from './ai-writer/ai-writer.service' AiTaskModule, TopicModule, DraftModule, + MembershipModule, forwardRef(() => NoteModule), ], providers: [ @@ -79,6 +85,9 @@ import { AiWriterService } from './ai-writer/ai-writer.service' AiAgentChatService, AiAgentConversationService, AiAgentConversationRepository, + AiTtsService, + AiTtsRepository, + AiTtsQueryService, ], controllers: [ AiController, @@ -89,6 +98,7 @@ import { AiWriterService } from './ai-writer/ai-writer.service' AiTranslationController, TranslationEntryController, AiAgentController, + AiTtsController, ], exports: [ AiService, @@ -99,6 +109,8 @@ import { AiWriterService } from './ai-writer/ai-writer.service' AiSummaryService, AiInsightsService, TranslationEntryService, + AiTtsService, + AiTtsQueryService, ], }) export class AiModule {} diff --git a/apps/core/src/modules/configs/configs.default.ts b/apps/core/src/modules/configs/configs.default.ts index 79d970a70b6..fd3532271e1 100644 --- a/apps/core/src/modules/configs/configs.default.ts +++ b/apps/core/src/modules/configs/configs.default.ts @@ -89,6 +89,18 @@ export const generateDefaultConfig: () => IConfig = () => ({ defaultQuality: 'standard', defaultFormat: 'png', }, + ttsOptions: { + enable: false, + provider: 'openrouter', + apiKey: '', + endpoint: '', + model: '', + voice: '', + speed: 1, + maxCharsPerChunk: 1800, + concurrency: 3, + maxCharsPerRun: 120000, + }, fileUploadOptions: { enableCustomNaming: false, filenameTemplate: '{Y}{m}{d}/{md5-16}{ext}', diff --git a/apps/core/src/modules/configs/configs.dsl.util.ts b/apps/core/src/modules/configs/configs.dsl.util.ts index 735dc332674..2ee3d54a978 100644 --- a/apps/core/src/modules/configs/configs.dsl.util.ts +++ b/apps/core/src/modules/configs/configs.dsl.util.ts @@ -128,7 +128,7 @@ const groupConfigs: GroupConfig[] = [ title: 'AI', description: 'AI summary, writing assistant, image generation', icon: 'sparkles', - sectionKeys: ['ai', 'imageGenerationOptions'], + sectionKeys: ['ai', 'imageGenerationOptions', 'ttsOptions'], }, { key: 'integrations', diff --git a/apps/core/src/modules/configs/configs.interface.ts b/apps/core/src/modules/configs/configs.interface.ts index c71028e0c8f..fdf3dd83f6d 100644 --- a/apps/core/src/modules/configs/configs.interface.ts +++ b/apps/core/src/modules/configs/configs.interface.ts @@ -21,6 +21,7 @@ import { type OAuthSchema, type SeoSchema, type ThirdPartyServiceIntegrationSchema, + type TtsOptionsSchema, type UrlSchema, } from './configs.schema' @@ -44,6 +45,7 @@ export abstract class IConfig { backupOptions: Required> imageStorageOptions: Required> imageGenerationOptions: Required> + ttsOptions: Required> fileUploadOptions: Required> commentUploadOptions: Required> baiduSearchOptions: Required> diff --git a/apps/core/src/modules/configs/configs.schema.ts b/apps/core/src/modules/configs/configs.schema.ts index ad74cb1f3c7..6e90e6b4a6a 100644 --- a/apps/core/src/modules/configs/configs.schema.ts +++ b/apps/core/src/modules/configs/configs.schema.ts @@ -330,6 +330,56 @@ export type ImageGenerationOptionsConfig = z.infer< typeof ImageGenerationOptionsSchema > +// ==================== TTS Options ==================== +export const TtsOptionsSchema = section('AI text to speech', { + enable: field.toggle(z.boolean().optional(), 'Enable AI narration'), + provider: field.select( + z.enum(['openrouter', 'openai', 'custom']).optional().default('openrouter'), + 'Provider', + [ + { label: 'OpenRouter', value: 'openrouter' }, + { label: 'OpenAI', value: 'openai' }, + { label: 'Custom', value: 'custom' }, + ], + { + description: + 'Endpoint preset. "custom" requires Endpoint to be set. The adapter appends /audio/speech.', + }, + ), + apiKey: field.password(nullableStorageText(), 'API Key'), + endpoint: field.plain(nullableStorageText(), 'Endpoint', { + 'ui:options': { showWhen: { provider: 'custom' } }, + }), + model: field.plain(nullableStorageText(), 'Model', { + description: 'Speech model id, e.g. openai/gpt-4o-mini-tts-2025-12-15', + }), + voice: field.plain(nullableStorageText(), 'Voice', { + description: 'Voice id. Availability depends on the model.', + }), + speed: field.number( + z.coerce.number().min(0.25).max(4).optional().default(1), + 'Speed', + { 'ui:options': { halfGrid: true } }, + ), + maxCharsPerChunk: field.number( + z.coerce.number().int().min(200).max(4000).optional().default(1800), + 'Max characters per request', + { 'ui:options': { halfGrid: true } }, + ), + concurrency: field.number( + z.coerce.number().int().min(1).max(8).optional().default(3), + 'Concurrent requests per task', + { 'ui:options': { halfGrid: true } }, + ), + maxCharsPerRun: field.number( + z.coerce.number().int().min(1000).max(1000000).optional().default(120000), + 'Max characters per run', + { 'ui:options': { halfGrid: true } }, + ), +}) +export class TtsOptionsDto extends createZodDto(TtsOptionsSchema) {} +export type TtsOptionsConfig = z.infer + // ==================== Comment Upload Options ==================== export const CommentUploadOptionsSchema = section('Comment image uploads', { enable: field.toggle( @@ -1074,6 +1124,7 @@ export const configSchemaMapping = { backupOptions: BackupOptionsSchema, imageStorageOptions: ImageStorageOptionsSchema, imageGenerationOptions: ImageGenerationOptionsSchema, + ttsOptions: TtsOptionsSchema, fileUploadOptions: FileUploadOptionsSchema, commentUploadOptions: CommentUploadOptionsSchema, baiduSearchOptions: BaiduSearchOptionsSchema, diff --git a/apps/core/src/modules/configs/configs.service.ts b/apps/core/src/modules/configs/configs.service.ts index b548a0c81e8..8fa234db691 100644 --- a/apps/core/src/modules/configs/configs.service.ts +++ b/apps/core/src/modules/configs/configs.service.ts @@ -45,9 +45,7 @@ const s3StorageNullDefaults = { function normalizeS3StorageOptionNulls(value: T): T { const normalized = { ...value } as Record - for (const [key, defaultValue] of Object.entries( - s3StorageNullDefaults, - )) { + for (const [key, defaultValue] of Object.entries(s3StorageNullDefaults)) { if (normalized[key] === null) { normalized[key] = defaultValue } @@ -387,6 +385,14 @@ export class ConfigsService implements OnModuleInit { this.validateImageGenerationProvider(nextConfig) } + if (key === 'ttsOptions') { + const nextConfig = await this.buildNextConfigForValidation( + key, + instanceValue, + ) + this.validateTtsProvider(nextConfig) + } + encryptObject(instanceValue, key) switch (key) { @@ -521,6 +527,16 @@ export class ConfigsService implements OnModuleInit { } } + private validateTtsProvider(config: IConfig) { + const { ttsOptions } = config + + if (ttsOptions.provider === 'custom' && !ttsOptions.endpoint) { + throw createAppException(AppErrorCode.CONFIG_VALIDATION_FAILED, { + message: 'ttsOptions.endpoint: required when provider is "custom"', + }) + } + } + private validWithDto(schema: z.ZodTypeAny, value: unknown): any { const result = schema.safeParse(value) if (!result.success) { diff --git a/apps/core/src/modules/file/file-reference-usage.repository.ts b/apps/core/src/modules/file/file-reference-usage.repository.ts index d236e4619c8..a4867408542 100644 --- a/apps/core/src/modules/file/file-reference-usage.repository.ts +++ b/apps/core/src/modules/file/file-reference-usage.repository.ts @@ -6,6 +6,7 @@ import { aiInsights, aiSummaries, aiTranslations, + aiTtsBlocks, comments, draftHistories, drafts, @@ -187,6 +188,10 @@ export class FileReferenceUsageRepository { SELECT 1 FROM ${aiInsights} AS item WHERE item.content ~ candidate.pattern ) + OR EXISTS ( + SELECT 1 FROM ${aiTtsBlocks} AS item + WHERE item.url ~ candidate.pattern + ) OR EXISTS ( SELECT 1 FROM ${translationEntries} AS item WHERE concat_ws(E'\n', item.source_text, item.translated_text) ~ candidate.pattern @@ -224,6 +229,7 @@ export class FileReferenceUsageRepository { translationRows, summaryRows, insightRows, + ttsBlockRows, translationEntryRows, serverlessStorageRows, ] = await Promise.all([ @@ -360,6 +366,9 @@ export class FileReferenceUsageRepository { this.db .select({ id: aiInsights.id, content: aiInsights.content }) .from(aiInsights), + this.db + .select({ id: aiTtsBlocks.id, url: aiTtsBlocks.url }) + .from(aiTtsBlocks), this.db .select({ id: translationEntries.id, @@ -407,6 +416,7 @@ export class FileReferenceUsageRepository { append('ai_translation', translationRows) append('ai_summary', summaryRows) append('ai_insight', insightRows) + append('ai_tts', ttsBlockRows) append('translation_entry', translationEntryRows) append('serverless_storage', serverlessStorageRows) diff --git a/apps/core/src/modules/file/file.service.ts b/apps/core/src/modules/file/file.service.ts index 83ed48aaffb..f117807126d 100644 --- a/apps/core/src/modules/file/file.service.ts +++ b/apps/core/src/modules/file/file.service.ts @@ -192,15 +192,56 @@ export class FileService { } } + async deleteObject(backend: 's3' | 'local', key: string): Promise { + if (backend === 'local') { + try { + await unlink(this.resolveFilePath('audio', key)) + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + return + } + + const config = await this.configService.get('imageStorageOptions') + if ( + !config?.endpoint || + !config.secretId || + !config.secretKey || + !config.bucket + ) { + throw createAppException(AppErrorCode.FILE_STORAGE_NOT_CONFIGURED) + } + + const s3Uploader = new S3Uploader({ + endpoint: config.endpoint, + accessKey: config.secretId, + secretKey: config.secretKey, + bucket: config.bucket, + region: config.region || 'auto', + }) + await s3Uploader.deleteObject(key) + } + async uploadBuffer( buffer: Buffer, opts: { type: FileType - originalFilename: string + originalFilename?: string contentType: string + objectKey?: string }, - ): Promise<{ url: string; name: string }> { - const { type, originalFilename, contentType } = opts + ): Promise<{ + url: string + name: string + storageBackend: 's3' | 'local' + storageKey: string + }> { + const { + type, + originalFilename = '', + contentType, + objectKey: explicitObjectKey, + } = opts const uploadConfig = await this.configService.get('fileUploadOptions') const imageStorageConfig = await this.configService.get( @@ -210,7 +251,10 @@ export class FileService { if ( s3Enabled && - (type === 'image' || type === 'file' || type === 'video') + (type === 'image' || + type === 'file' || + type === 'video' || + type === 'audio') ) { const config = imageStorageConfig! if ( @@ -222,21 +266,28 @@ export class FileService { throw createAppException(AppErrorCode.FILE_STORAGE_NOT_CONFIGURED) } - const filename = generateFilename(uploadConfig, { - originalFilename, - fileType: type, - }) - - let prefixPath = '' - if (config.prefix) { - prefixPath = replaceFilenameTemplate(config.prefix, { + let objectKey: string + let filename: string + if (explicitObjectKey) { + objectKey = explicitObjectKey + filename = path.basename(explicitObjectKey) + } else { + filename = generateFilename(uploadConfig, { originalFilename, fileType: type, }) - prefixPath = prefixPath.replace(/\/+$/, '') - } - const objectKey = prefixPath ? `${prefixPath}/${filename}` : filename + let prefixPath = '' + if (config.prefix) { + prefixPath = replaceFilenameTemplate(config.prefix, { + originalFilename, + fileType: type, + }) + prefixPath = prefixPath.replace(/\/+$/, '') + } + + objectKey = prefixPath ? `${prefixPath}/${filename}` : filename + } const s3Uploader = new S3Uploader({ endpoint: config.endpoint, @@ -261,27 +312,36 @@ export class FileService { objectKey, ) - return { url: s3Url, name: filename } + return { + url: s3Url, + name: filename, + storageBackend: 's3', + storageKey: objectKey, + } } - const rawFilename = generateFilename(uploadConfig, { - originalFilename, - fileType: type, - }) - - const basePath = generateFilePath(uploadConfig, { - originalFilename, - fileType: type, - }) - let relativePath: string - if (basePath === type || !basePath) { - relativePath = rawFilename + if (explicitObjectKey) { + relativePath = explicitObjectKey } else { - const pathWithoutType = basePath.startsWith(`${type}/`) - ? basePath.slice(Math.max(0, type.length + 1)) - : basePath - relativePath = path.join(pathWithoutType, rawFilename) + const rawFilename = generateFilename(uploadConfig, { + originalFilename, + fileType: type, + }) + + const basePath = generateFilePath(uploadConfig, { + originalFilename, + fileType: type, + }) + + if (basePath === type || !basePath) { + relativePath = rawFilename + } else { + const pathWithoutType = basePath.startsWith(`${type}/`) + ? basePath.slice(Math.max(0, type.length + 1)) + : basePath + relativePath = path.join(pathWithoutType, rawFilename) + } } const fileUrl = await this.writeTrackedOwnerFile( @@ -290,6 +350,11 @@ export class FileService { Readable.from(buffer), ) - return { url: fileUrl, name: path.basename(relativePath) } + return { + url: fileUrl, + name: path.basename(relativePath), + storageBackend: 'local', + storageKey: relativePath, + } } } diff --git a/apps/core/src/modules/file/file.type.ts b/apps/core/src/modules/file/file.type.ts index d58851f5094..41ae0da174c 100644 --- a/apps/core/src/modules/file/file.type.ts +++ b/apps/core/src/modules/file/file.type.ts @@ -4,5 +4,6 @@ export enum FileTypeEnum { avatar = 'avatar', image = 'image', video = 'video', + audio = 'audio', } export type FileType = keyof typeof FileTypeEnum diff --git a/apps/core/src/modules/membership/entitlement.service.ts b/apps/core/src/modules/membership/entitlement.service.ts index 815fcdea888..b50994b2406 100644 --- a/apps/core/src/modules/membership/entitlement.service.ts +++ b/apps/core/src/modules/membership/entitlement.service.ts @@ -37,6 +37,25 @@ export class EntitlementService { return active } + async isEntitledToPremium(input: { + isOwner: boolean + readerId?: string + }): Promise { + if (input.isOwner) return true + if (!input.readerId) return false + return this.isActiveMember(input.readerId) + } + + async isPremiumLocked(input: { + isPremium?: boolean | null + isOwner: boolean + readerId?: string + }): Promise { + if (!input.isPremium) return false + if (!(await this.isMembershipPurchasable())) return false + return !(await this.isEntitledToPremium(input)) + } + async getAvailability(): Promise { const config = await this.configsService.get('membership') return resolveMembershipAvailability(config) diff --git a/apps/core/src/modules/note/note-meta-builder.ts b/apps/core/src/modules/note/note-meta-builder.ts index 2d7634fae93..bfa44372205 100644 --- a/apps/core/src/modules/note/note-meta-builder.ts +++ b/apps/core/src/modules/note/note-meta-builder.ts @@ -2,6 +2,7 @@ import type { InsightsMeta, NoteResponseMeta, SummaryMeta, + TtsMeta, } from '~/common/response/meta.types' import { NoteResponseMetaSchema } from '~/common/response/meta.types' import { MetaObjectBuilder } from '~/common/response/meta-builder' @@ -22,4 +23,9 @@ export class NoteMetaBuilder extends MetaObjectBuilder< ;(this.meta as NoteResponseMeta).summary = value return this } + + tts(value: TtsMeta): this { + ;(this.meta as NoteResponseMeta).tts = value + return this + } } diff --git a/apps/core/src/modules/note/note.controller.ts b/apps/core/src/modules/note/note.controller.ts index 688a7512e69..f7b25007015 100644 --- a/apps/core/src/modules/note/note.controller.ts +++ b/apps/core/src/modules/note/note.controller.ts @@ -42,6 +42,7 @@ import { DEFAULT_SUMMARY_LANG } from '../ai/ai.constants' import { AiInsightsService } from '../ai/ai-insights/ai-insights.service' import { parseLanguageCode } from '../ai/ai-language.util' import { AiSummaryService } from '../ai/ai-summary/ai-summary.service' +import { AiTtsQueryService } from '../ai/ai-tts/ai-tts-query.service' import { EnrichmentService } from '../enrichment/enrichment.service' import { ListQueryDto, @@ -84,6 +85,7 @@ export class NoteController { private readonly translationService: TranslationService, private readonly aiSummaryService: AiSummaryService, private readonly aiInsightsService: AiInsightsService, + private readonly aiTtsQueryService: AiTtsQueryService, private readonly lexicalService: LexicalService, private readonly enrichmentService: EnrichmentService, private readonly translationEntryService: TranslationEntryService, @@ -236,17 +238,26 @@ export class NoteController { applyArticleTranslationInPlace(current, translationResult) const insightsLang = parseLanguageCode(lang) - const [hasInsightsInLocale, summaryDoc] = await Promise.all([ + const [hasInsightsInLocale, summaryDoc, ttsMeta] = await Promise.all([ this.aiInsightsService .hasInsightsInLang(current.id!, insightsLang) .catch(() => false), this.aiSummaryService.getSummaryForPublicMeta(current.id!, insightsLang), + this.aiTtsQueryService + .getMetaForArticle(current.id!, insightsLang, current.modifiedAt) + .catch(() => ({ available: false as const })), ]) + // A future-dated secret note has its text blanked for anonymous readers, so + // narration of it is exactly what the secret withholds. + const narrationVisible = + isAuthenticated || !this.noteService.checkNoteIsSecret(current) + const metaBuilder = new NoteMetaBuilder() .view('detail') .interaction({ isLiked: liked }) .insights({ hasInLocale: hasInsightsInLocale }) + .tts(narrationVisible ? ttsMeta : { available: false }) if (summaryDoc) { metaBuilder.summary({ @@ -635,9 +646,14 @@ export class NoteController { } const insightsLang = parseLanguageCode(lang) - const hasInsightsInLocale = await this.aiInsightsService - .hasInsightsInLang(latest.id!, insightsLang) - .catch(() => false) + const [hasInsightsInLocale, ttsMeta] = await Promise.all([ + this.aiInsightsService + .hasInsightsInLang(latest.id!, insightsLang) + .catch(() => false), + this.aiTtsQueryService + .getMetaForArticle(latest.id!, insightsLang, latest.modifiedAt) + .catch(() => ({ available: false as const })), + ]) const { enrichments, ...latestData } = await this.enrichmentService.attachEnrichments(latest) @@ -645,6 +661,7 @@ export class NoteController { const metaBuilder = new NoteMetaBuilder() .view('detail') .insights({ hasInLocale: hasInsightsInLocale }) + .tts(ttsMeta) .enrichments(enrichments as Record) const translationMap = new Map([ diff --git a/apps/core/src/modules/post/post-meta-builder.ts b/apps/core/src/modules/post/post-meta-builder.ts index df8db31764b..d3b8b6251d6 100644 --- a/apps/core/src/modules/post/post-meta-builder.ts +++ b/apps/core/src/modules/post/post-meta-builder.ts @@ -5,6 +5,7 @@ import type { PostResponseMeta, RelatedRef, SummaryMeta, + TtsMeta, } from '~/common/response/meta.types' import { PostResponseMetaSchema } from '~/common/response/meta.types' import { MetaObjectBuilder } from '~/common/response/meta-builder' @@ -46,4 +47,9 @@ export class PostMetaBuilder extends MetaObjectBuilder< ;(this.meta as PostResponseMeta).paywall = value return this } + + tts(value: TtsMeta): this { + ;(this.meta as PostResponseMeta).tts = value + return this + } } diff --git a/apps/core/src/modules/post/post.controller.ts b/apps/core/src/modules/post/post.controller.ts index 4fc9c262735..a388ad927b2 100644 --- a/apps/core/src/modules/post/post.controller.ts +++ b/apps/core/src/modules/post/post.controller.ts @@ -47,6 +47,7 @@ import { EntityIdDto } from '~/shared/dto/id.dto' import { AiInsightsService } from '../ai/ai-insights/ai-insights.service' import { parseLanguageCode } from '../ai/ai-language.util' import { AiSummaryService } from '../ai/ai-summary/ai-summary.service' +import { AiTtsQueryService } from '../ai/ai-tts/ai-tts-query.service' import { EnrichmentService } from '../enrichment/enrichment.service' import { SnippetService } from '../snippet/snippet.service' import { @@ -78,6 +79,7 @@ export class PostController { private readonly translationService: TranslationService, private readonly aiInsightsService: AiInsightsService, private readonly aiSummaryService: AiSummaryService, + private readonly aiTtsQueryService: AiTtsQueryService, private readonly enrichmentService: EnrichmentService, private readonly translationEntryService: TranslationEntryService, private readonly snippetService: SnippetService, @@ -93,11 +95,10 @@ export class PostController { if (!(await this.entitlementService.isMembershipPurchasable())) return null - const isEntitled = - isOwner || - (readerId - ? await this.entitlementService.isActiveMember(readerId) - : false) + const isEntitled = await this.entitlementService.isEntitledToPremium({ + isOwner, + readerId, + }) if (isEntitled) { return { locked: false } @@ -406,6 +407,7 @@ export class PostController { entryMaps, hasInsightsInLocale, summaryDoc, + ttsMeta, ] = await Promise.all([ this.translationService.translateArticle({ articleId: postDocument.id, @@ -427,6 +429,13 @@ export class PostController { postDocument.id, insightsLang, ), + this.aiTtsQueryService + .getMetaForArticle( + postDocument.id, + insightsLang, + postDocument.modifiedAt, + ) + .catch(() => ({ available: false as const })), ]) applyArticleTranslationInPlace( @@ -473,6 +482,7 @@ export class PostController { .interaction({ isLiked: liked }) .related(translatedRelated) .insights({ hasInLocale: hasInsightsInLocale }) + .tts(paywall?.locked ? { available: false } : ttsMeta) .enrichments(enrichments as Record) if (summaryDoc && !paywall?.locked) { diff --git a/apps/core/src/processors/database/repository.tokens.ts b/apps/core/src/processors/database/repository.tokens.ts index 93d418a3b3d..2c1e6e546c7 100644 --- a/apps/core/src/processors/database/repository.tokens.ts +++ b/apps/core/src/processors/database/repository.tokens.ts @@ -23,6 +23,7 @@ export const POSTGRES_REPOSITORY_TOKENS = { aiTranslation: Symbol('AiTranslationRepository'), translationEntry: Symbol('TranslationEntryRepository'), aiAgentConversation: Symbol('AiAgentConversationRepository'), + aiTts: Symbol('AiTtsRepository'), activity: Symbol('ActivityRepository'), analyze: Symbol('AnalyzeRepository'), fileReference: Symbol('FileReferenceRepository'), diff --git a/apps/core/src/processors/helper/helper.lexical.service.ts b/apps/core/src/processors/helper/helper.lexical.service.ts index fd277dddd6c..62717ddfc9d 100644 --- a/apps/core/src/processors/helper/helper.lexical.service.ts +++ b/apps/core/src/processors/helper/helper.lexical.service.ts @@ -234,7 +234,9 @@ export class LexicalService { : { content, changed: false } } - extractRootBlocks(content: string): LexicalRootBlock[] { + extractRootBlockNodes( + content: string, + ): Array<{ id: string | null; type: string; node: any; index: number }> { const editorState = this.parseEditorState(content) if (!editorState?.root || !Array.isArray(editorState.root.children)) { return [] @@ -242,24 +244,36 @@ export class LexicalService { return editorState.root.children .map((child: any, index: number) => { - if (!child || typeof child !== 'object') { - return null + if (!child || typeof child !== 'object') return null + return { + id: this.readBlockId(child), + type: typeof child.type === 'string' ? child.type : 'unknown', + node: child, + index, } + }) + .filter(Boolean) as Array<{ + id: string | null + type: string + node: any + index: number + }> + } - const text = this.extractBlockText(child) + extractRootBlocks(content: string): LexicalRootBlock[] { + return this.extractRootBlockNodes(content).map( + ({ id, type, node, index }) => { + const text = this.extractBlockText(node) const normalized = this.normalizeText(text) - const type = typeof child.type === 'string' ? child.type : 'unknown' - const fingerprint = md5(`${type}:${normalized}`) - return { - id: this.readBlockId(child), + id, type, text, - fingerprint, + fingerprint: md5(`${type}:${normalized}`), index, } satisfies LexicalRootBlock - }) - .filter((block): block is LexicalRootBlock => !!block) + }, + ) } lexicalToMarkdown(editorState: string): string { diff --git a/apps/core/test/src/contracts/admin/notes-admin.contract.spec.ts b/apps/core/test/src/contracts/admin/notes-admin.contract.spec.ts index 80357c3c351..31e43ac3356 100644 --- a/apps/core/test/src/contracts/admin/notes-admin.contract.spec.ts +++ b/apps/core/test/src/contracts/admin/notes-admin.contract.spec.ts @@ -17,6 +17,7 @@ import { describe, expect, test } from 'vitest' import { apiRoutePrefix } from '~/common/decorators/api-controller.decorator' import { AiInsightsService } from '~/modules/ai/ai-insights/ai-insights.service' import { AiSummaryService } from '~/modules/ai/ai-summary/ai-summary.service' +import { AiTtsQueryService } from '~/modules/ai/ai-tts/ai-tts-query.service' import { NoteController } from '~/modules/note/note.controller' import { NoteService } from '~/modules/note/note.service' import { LexicalService } from '~/processors/helper/helper.lexical.service' @@ -156,6 +157,15 @@ const lexicalServiceProvider = { }, } +const aiTtsQueryProvider = { + provide: AiTtsQueryService, + useValue: { + async getMetaForArticle() { + return { available: false } + }, + }, +} + const NOTE_LIST_REQUIRED_KEYS = [ 'id', 'nid', @@ -196,6 +206,7 @@ describe('NoteController admin contract (e2e)', () => { enrichmentProvider, aiSummaryProvider, aiInsightsProvider, + aiTtsQueryProvider, lexicalServiceProvider, ], }) diff --git a/apps/core/test/src/contracts/admin/posts-admin.contract.spec.ts b/apps/core/test/src/contracts/admin/posts-admin.contract.spec.ts index 68d6f6827ad..731e49d4d56 100644 --- a/apps/core/test/src/contracts/admin/posts-admin.contract.spec.ts +++ b/apps/core/test/src/contracts/admin/posts-admin.contract.spec.ts @@ -19,6 +19,7 @@ import { describe, expect, test } from 'vitest' import { apiRoutePrefix } from '~/common/decorators/api-controller.decorator' import { AiInsightsService } from '~/modules/ai/ai-insights/ai-insights.service' import { AiSummaryService } from '~/modules/ai/ai-summary/ai-summary.service' +import { AiTtsQueryService } from '~/modules/ai/ai-tts/ai-tts-query.service' import { PostController } from '~/modules/post/post.controller' import { PostService } from '~/modules/post/post.service' @@ -118,6 +119,15 @@ const aiSummaryProvider = { }, } +const aiTtsQueryProvider = { + provide: AiTtsQueryService, + useValue: { + async getMetaForArticle() { + return { available: false } + }, + }, +} + // Keys (snake_case post-interceptor) the admin list dereferences directly. const POST_LIST_REQUIRED_KEYS = [ 'id', @@ -157,6 +167,7 @@ describe('PostController admin contract (e2e)', () => { enrichmentProvider, aiInsightsProvider, aiSummaryProvider, + aiTtsQueryProvider, snippetProvider, entitlementProvider, ], diff --git a/apps/core/test/src/contracts/note.contract.spec.ts b/apps/core/test/src/contracts/note.contract.spec.ts index e813269e43c..cfe9a7ab8c9 100644 --- a/apps/core/test/src/contracts/note.contract.spec.ts +++ b/apps/core/test/src/contracts/note.contract.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest' import { apiRoutePrefix } from '~/common/decorators/api-controller.decorator' import { AiInsightsService } from '~/modules/ai/ai-insights/ai-insights.service' import { AiSummaryService } from '~/modules/ai/ai-summary/ai-summary.service' +import { AiTtsQueryService } from '~/modules/ai/ai-tts/ai-tts-query.service' import { NoteController } from '~/modules/note/note.controller' import { NoteService } from '~/modules/note/note.service' import { LexicalService } from '~/processors/helper/helper.lexical.service' @@ -169,6 +170,15 @@ const lexicalServiceProvider = { }, } +const aiTtsQueryProvider = { + provide: AiTtsQueryService, + useValue: { + async getMetaForArticle() { + return { available: false } + }, + }, +} + describe('NoteController contract (e2e)', () => { const proxy = createE2EApp({ controllers: [NoteController], @@ -180,6 +190,7 @@ describe('NoteController contract (e2e)', () => { enrichmentProvider, aiSummaryProvider, aiInsightsProvider, + aiTtsQueryProvider, lexicalServiceProvider, ], }) diff --git a/apps/core/test/src/contracts/post.contract.spec.ts b/apps/core/test/src/contracts/post.contract.spec.ts index bbe82f3931b..2cfadd26d90 100644 --- a/apps/core/test/src/contracts/post.contract.spec.ts +++ b/apps/core/test/src/contracts/post.contract.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest' import { apiRoutePrefix } from '~/common/decorators/api-controller.decorator' import { AiInsightsService } from '~/modules/ai/ai-insights/ai-insights.service' import { AiSummaryService } from '~/modules/ai/ai-summary/ai-summary.service' +import { AiTtsQueryService } from '~/modules/ai/ai-tts/ai-tts-query.service' import { PostController } from '~/modules/post/post.controller' import { PostService } from '~/modules/post/post.service' @@ -126,6 +127,15 @@ const aiSummaryProvider = { }, } +const aiTtsQueryProvider = { + provide: AiTtsQueryService, + useValue: { + async getMetaForArticle() { + return { available: false } + }, + }, +} + describe('PostController contract (e2e)', () => { const proxy = createE2EApp({ controllers: [PostController], @@ -137,6 +147,7 @@ describe('PostController contract (e2e)', () => { enrichmentProvider, aiInsightsProvider, aiSummaryProvider, + aiTtsQueryProvider, snippetProvider, entitlementProvider, ], diff --git a/apps/core/test/src/contracts/yohaku/note-detail.contract.spec.ts b/apps/core/test/src/contracts/yohaku/note-detail.contract.spec.ts index 93930c061cd..86817eac0c1 100644 --- a/apps/core/test/src/contracts/yohaku/note-detail.contract.spec.ts +++ b/apps/core/test/src/contracts/yohaku/note-detail.contract.spec.ts @@ -17,6 +17,7 @@ import { describe, expect, test } from 'vitest' import { apiRoutePrefix } from '~/common/decorators/api-controller.decorator' import { AiInsightsService } from '~/modules/ai/ai-insights/ai-insights.service' import { AiSummaryService } from '~/modules/ai/ai-summary/ai-summary.service' +import { AiTtsQueryService } from '~/modules/ai/ai-tts/ai-tts-query.service' import { NoteController } from '~/modules/note/note.controller' import { NoteService } from '~/modules/note/note.service' import { LexicalService } from '~/processors/helper/helper.lexical.service' @@ -152,6 +153,15 @@ const lexicalServiceProvider = { }, } +const aiTtsQueryProvider = { + provide: AiTtsQueryService, + useValue: { + async getMetaForArticle() { + return { available: false } + }, + }, +} + describe('Yohaku contract — note detail (e2e)', () => { const proxy = createE2EApp({ controllers: [NoteController], @@ -163,6 +173,7 @@ describe('Yohaku contract — note detail (e2e)', () => { enrichmentProvider, aiSummaryProvider, aiInsightsProvider, + aiTtsQueryProvider, lexicalServiceProvider, ], }) diff --git a/apps/core/test/src/contracts/yohaku/post-detail.contract.spec.ts b/apps/core/test/src/contracts/yohaku/post-detail.contract.spec.ts index 062553405b1..d15f240640d 100644 --- a/apps/core/test/src/contracts/yohaku/post-detail.contract.spec.ts +++ b/apps/core/test/src/contracts/yohaku/post-detail.contract.spec.ts @@ -22,6 +22,7 @@ import { describe, expect, test } from 'vitest' import { apiRoutePrefix } from '~/common/decorators/api-controller.decorator' import { AiInsightsService } from '~/modules/ai/ai-insights/ai-insights.service' import { AiSummaryService } from '~/modules/ai/ai-summary/ai-summary.service' +import { AiTtsQueryService } from '~/modules/ai/ai-tts/ai-tts-query.service' import { PostController } from '~/modules/post/post.controller' import { PostService } from '~/modules/post/post.service' @@ -123,6 +124,15 @@ const aiSummaryProvider = { }, } +const aiTtsQueryProvider = { + provide: AiTtsQueryService, + useValue: { + async getMetaForArticle() { + return { available: false } + }, + }, +} + describe('Yohaku contract — post detail (e2e)', () => { const proxy = createE2EApp({ controllers: [PostController], @@ -134,6 +144,7 @@ describe('Yohaku contract — post detail (e2e)', () => { enrichmentProvider, aiInsightsProvider, aiSummaryProvider, + aiTtsQueryProvider, snippetProvider, entitlementProvider, ], diff --git a/apps/core/test/src/contracts/yohaku/post-list.contract.spec.ts b/apps/core/test/src/contracts/yohaku/post-list.contract.spec.ts index 2369bd99a2b..99c6cd677a1 100644 --- a/apps/core/test/src/contracts/yohaku/post-list.contract.spec.ts +++ b/apps/core/test/src/contracts/yohaku/post-list.contract.spec.ts @@ -15,6 +15,7 @@ import { describe, expect, test } from 'vitest' import { apiRoutePrefix } from '~/common/decorators/api-controller.decorator' import { AiInsightsService } from '~/modules/ai/ai-insights/ai-insights.service' import { AiSummaryService } from '~/modules/ai/ai-summary/ai-summary.service' +import { AiTtsQueryService } from '~/modules/ai/ai-tts/ai-tts-query.service' import { PostController } from '~/modules/post/post.controller' import { PostService } from '~/modules/post/post.service' @@ -101,6 +102,15 @@ const aiSummaryProvider = { }, } +const aiTtsQueryProvider = { + provide: AiTtsQueryService, + useValue: { + async getMetaForArticle() { + return { available: false } + }, + }, +} + describe('Yohaku contract — post list (e2e)', () => { const proxy = createE2EApp({ controllers: [PostController], @@ -112,6 +122,7 @@ describe('Yohaku contract — post list (e2e)', () => { enrichmentProvider, aiInsightsProvider, aiSummaryProvider, + aiTtsQueryProvider, snippetProvider, entitlementProvider, ], diff --git a/apps/core/test/src/modules/ai/ai-task/ai-task.service.spec.ts b/apps/core/test/src/modules/ai/ai-task/ai-task.service.spec.ts index fb5dca6b35d..af40fc79d77 100644 --- a/apps/core/test/src/modules/ai/ai-task/ai-task.service.spec.ts +++ b/apps/core/test/src/modules/ai/ai-task/ai-task.service.spec.ts @@ -55,3 +55,46 @@ describe('AiTaskService.createImageGenerationTask', () => { ) }) }) + +describe('AiTaskService.createTtsTask', () => { + it('separates a forced run from an incremental one in the dedup key', async () => { + const { service, taskQueueService } = createService() + + await service.createTtsTask({ refId: '1', langs: ['zh', 'en'], title: 'T' }) + await service.createTtsTask({ + refId: '1', + langs: ['en', 'zh'], + force: true, + title: 'T', + }) + + expect(taskQueueService.createTask).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + type: AITaskType.Tts, + dedupKey: '1:inc:en,zh', + }), + ) + expect(taskQueueService.createTask).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ dedupKey: '1:force:en,zh' }), + ) + }) + + it('collapses equivalent language codes onto one dedup key', async () => { + const { service, taskQueueService } = createService() + + await service.createTtsTask({ refId: '1', langs: ['zh-CN'], title: 'T' }) + await service.createTtsTask({ refId: '1', langs: ['zh'], title: 'T' }) + await service.createTtsTask({ + refId: '1', + langs: ['zh', 'zh-CN'], + title: 'T', + }) + + const keys = taskQueueService.createTask.mock.calls.map( + ([options]) => options.dedupKey, + ) + expect(keys).toEqual(['1:inc:zh', '1:inc:zh', '1:inc:zh']) + }) +}) diff --git a/apps/core/test/src/modules/ai/ai-tts/ai-tts-cleanup.spec.ts b/apps/core/test/src/modules/ai/ai-tts/ai-tts-cleanup.spec.ts new file mode 100644 index 00000000000..b119732c177 --- /dev/null +++ b/apps/core/test/src/modules/ai/ai-tts/ai-tts-cleanup.spec.ts @@ -0,0 +1,75 @@ +import { createPgRepositoryMock } from 'test/helper/pg-repository-mock' +import { describe, expect, it, vi } from 'vitest' + +import type { AiTtsRepository } from '~/modules/ai/ai-tts/ai-tts.repository' +import { AiTtsService } from '~/modules/ai/ai-tts/ai-tts.service' + +// `@nestjs/event-emitter`'s `OnEvent.KEY` static property does not survive +// this project's ESM/CJS interop in tests; the metadata key itself is a +// stable literal (`extend-metadata.util.js`), so we assert against that. +const EVENT_LISTENER_METADATA = 'EVENT_LISTENER_METADATA' + +function createHarness() { + const configService = { get: vi.fn(async () => ({})) } + const fileService = { + deleteObject: vi.fn(async () => {}), + } + const taskProcessor = { registerHandler: vi.fn() } + const repository = createPgRepositoryMock() + const databaseService = { findGlobalById: vi.fn() } + const lexicalService = { extractRootBlockNodes: vi.fn() } + const translationRepository = { findByRefAndLang: vi.fn() } + const redisService = { getClient: vi.fn() } + + const service = new AiTtsService( + configService as any, + fileService as any, + taskProcessor as any, + repository as any, + databaseService as any, + lexicalService as any, + translationRepository as any, + redisService as any, + ) + + return { configService, fileService, repository, service } +} + +describe('AiTtsService.handleArticleDeleted', () => { + it('removes rows and their objects when an article is deleted', async () => { + const { fileService, repository, service } = createHarness() + repository.deleteByRefId.mockResolvedValue([ + { storageBackend: 's3', storageKey: 'k/a' }, + { storageBackend: 'local', storageKey: 'tts/1/zh/b.mp3' }, + ] as any) + + await service.handleArticleDeleted('1') + + expect(repository.deleteByRefId).toHaveBeenCalledWith('1') + expect(fileService.deleteObject).toHaveBeenCalledWith('s3', 'k/a') + expect(fileService.deleteObject).toHaveBeenCalledWith( + 'local', + 'tts/1/zh/b.mp3', + ) + }) + + it('survives an object deletion failure', async () => { + const { fileService, repository, service } = createHarness() + repository.deleteByRefId.mockResolvedValue([ + { storageBackend: 's3', storageKey: 'k/a' }, + ] as any) + fileService.deleteObject.mockRejectedValue(new Error('network')) + + await expect(service.handleArticleDeleted('1')).resolves.toBeUndefined() + }) + + it('is wired to the post/note/page delete events', () => { + const listeners: Array<{ event: string }> = Reflect.getMetadata( + EVENT_LISTENER_METADATA, + AiTtsService.prototype.handleDeleteArticle, + ) + expect(listeners.map((listener) => listener.event)).toEqual( + expect.arrayContaining(['POST_DELETE', 'NOTE_DELETE', 'PAGE_DELETE']), + ) + }) +}) diff --git a/apps/core/test/src/modules/ai/ai-tts/ai-tts-meta.spec.ts b/apps/core/test/src/modules/ai/ai-tts/ai-tts-meta.spec.ts new file mode 100644 index 00000000000..506cdb7b09f --- /dev/null +++ b/apps/core/test/src/modules/ai/ai-tts/ai-tts-meta.spec.ts @@ -0,0 +1,136 @@ +import { createPgRepositoryMock } from 'test/helper/pg-repository-mock' +import { describe, expect, it, vi } from 'vitest' + +import type { AiTtsRepository } from '~/modules/ai/ai-tts/ai-tts.repository' +import { AiTtsQueryService } from '~/modules/ai/ai-tts/ai-tts-query.service' + +function createHarness() { + const repository = createPgRepositoryMock() + const databaseService = { findGlobalById: vi.fn() } + const service = new AiTtsQueryService( + repository as any, + databaseService as any, + { isPremiumLocked: vi.fn(async () => false) } as any, + { checkPasswordToAccess: vi.fn(async () => true) } as any, + ) + return { repository, service } +} + +describe('AiTtsQueryService.getMetaForArticle', () => { + it('reports available with the block count', async () => { + const { repository, service } = createHarness() + repository.findMeta.mockResolvedValue({ + id: '1', + updatedAt: new Date('2026-01-02'), + blockCount: 3, + sourceModifiedAt: new Date('2026-01-02'), + }) + + await expect( + service.getMetaForArticle('1', 'zh', new Date('2026-01-02')), + ).resolves.toEqual({ + available: true, + lang: 'zh', + blockCount: 3, + stale: false, + updatedAt: new Date('2026-01-02'), + }) + }) + + it('marks narration stale when the article was edited later', async () => { + const { repository, service } = createHarness() + repository.findMeta.mockResolvedValue({ + id: '1', + updatedAt: new Date('2026-01-02'), + blockCount: 3, + sourceModifiedAt: new Date('2026-01-02'), + }) + + const meta = await service.getMetaForArticle( + '1', + 'zh', + new Date('2026-03-01'), + ) + expect(meta.stale).toBe(true) + }) + + it('reports unavailable on a miss', async () => { + const { repository, service } = createHarness() + repository.findMeta.mockResolvedValue(null) + await expect( + service.getMetaForArticle('1', 'zh', new Date()), + ).resolves.toEqual({ available: false }) + }) + + it('reports unavailable when the parent exists but block_order was never published', async () => { + const { repository, service } = createHarness() + repository.findMeta.mockResolvedValue({ + id: '1', + updatedAt: new Date('2026-01-02'), + blockCount: 0, + sourceModifiedAt: null, + }) + + await expect( + service.getMetaForArticle('1', 'zh', new Date()), + ).resolves.toEqual({ available: false }) + }) + + it('does not call the repository twice or load block rows for a single lookup', async () => { + const { repository, service } = createHarness() + repository.findMeta.mockResolvedValue(null) + + await service.getMetaForArticle('1', 'zh', new Date()) + + expect(repository.findMeta).toHaveBeenCalledTimes(1) + expect(repository.findMeta).toHaveBeenCalledWith('1', 'zh') + expect(repository.findBlocks).not.toHaveBeenCalled() + }) + + it('is not stale for a translation whose sourceModifiedAt already carries the translation vintage', async () => { + const { repository, service } = createHarness() + repository.findMeta.mockResolvedValue({ + id: '1', + updatedAt: new Date('2026-02-15'), + blockCount: 2, + sourceModifiedAt: new Date('2026-02-10'), + }) + + const meta = await service.getMetaForArticle( + '1', + 'en', + new Date('2026-01-01'), + ) + expect(meta.stale).toBe(false) + }) + + it('is stale for a translation whose vintage predates a later article edit', async () => { + const { repository, service } = createHarness() + repository.findMeta.mockResolvedValue({ + id: '1', + updatedAt: new Date('2026-01-05'), + blockCount: 2, + sourceModifiedAt: new Date('2026-01-05'), + }) + + const meta = await service.getMetaForArticle( + '1', + 'en', + new Date('2026-02-01'), + ) + expect(meta.stale).toBe(true) + }) + + it('treats a missing modifiedAt as never stale', async () => { + const { repository, service } = createHarness() + repository.findMeta.mockResolvedValue({ + id: '1', + updatedAt: new Date('2026-01-02'), + blockCount: 1, + sourceModifiedAt: new Date('2026-01-02'), + }) + + const meta = await service.getMetaForArticle('1', 'zh', null) + expect(meta.stale).toBe(false) + }) +}) diff --git a/apps/core/test/src/modules/ai/ai-tts/ai-tts-query.service.spec.ts b/apps/core/test/src/modules/ai/ai-tts/ai-tts-query.service.spec.ts new file mode 100644 index 00000000000..3226eea65da --- /dev/null +++ b/apps/core/test/src/modules/ai/ai-tts/ai-tts-query.service.spec.ts @@ -0,0 +1,617 @@ +import { createPgRepositoryMock, now } from 'test/helper/pg-repository-mock' +import { describe, expect, it, vi } from 'vitest' + +import { CollectionRefTypes } from '~/constants/db.constant' +import type { AiTtsRepository } from '~/modules/ai/ai-tts/ai-tts.repository' +import { AiTtsQueryService } from '~/modules/ai/ai-tts/ai-tts-query.service' + +const parentRow = (overrides: Record = {}) => ({ + id: 'tts-1', + createdAt: now, + updatedAt: now, + refId: '1', + lang: 'zh', + isTranslation: false, + sourceLang: null, + model: 'gpt-4o-mini-tts', + voice: 'alloy', + speed: 1, + format: 'mp3', + blockOrder: ['blk-a', 'blk-b'], + charCount: 20, + totalDurationMs: null, + sourceModifiedAt: now, + ...overrides, +}) + +const blockRow = ( + blockId: string, + overrides: Record = {}, +) => ({ + id: `row-${blockId}`, + createdAt: now, + ttsId: 'tts-1', + blockId, + fingerprint: 'fp', + chunkIndex: 0, + text: 'narrated text', + url: `https://cdn.example.com/${blockId}.mp3`, + storageBackend: 's3' as const, + storageKey: `k/${blockId}`, + byteSize: 1, + durationMs: null, + ...overrides, +}) + +function createHarness(options: { storedNotePassword?: string } = {}) { + const repository = createPgRepositoryMock() + const databaseService = { + findGlobalById: vi.fn(), + getRefArticleMap: vi.fn().mockResolvedValue({}), + findAllArticlesForAIText: vi.fn().mockResolvedValue({ + posts: [], + notes: [], + }), + } + const entitlementService = { + isPremiumLocked: vi.fn( + async (input: { + isPremium?: boolean | null + isOwner: boolean + readerId?: string + }) => Boolean(input.isPremium) && !input.isOwner && !input.readerId, + ), + } + const noteService = { + checkPasswordToAccess: vi.fn(async (_id: string, password?: string) => { + if (!options.storedNotePassword) return true + return password === options.storedNotePassword + }), + } + const service = new AiTtsQueryService( + repository as any, + databaseService as any, + entitlementService as any, + noteService as any, + ) + return { + repository, + databaseService, + entitlementService, + noteService, + service, + } +} + +describe('AiTtsQueryService.getPublicNarration', () => { + it('returns null when the article does not exist', async () => { + const { databaseService, repository, service } = createHarness() + databaseService.findGlobalById.mockResolvedValue(null) + + await expect(service.getPublicNarration('missing')).resolves.toBeNull() + expect(repository.findByRefAndLang).not.toHaveBeenCalled() + }) + + it('returns null for an unpublished (draft) post without touching the repository', async () => { + const { databaseService, repository, service } = createHarness() + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Post, + document: { id: '1', title: 'Draft', isPublished: false }, + }) + + await expect(service.getPublicNarration('1')).resolves.toBeNull() + expect(repository.findByRefAndLang).not.toHaveBeenCalled() + }) + + it('returns null for a password-protected note when no password is supplied', async () => { + const { databaseService, repository, service } = createHarness({ + storedNotePassword: 'letmein', + }) + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Note, + document: { + id: '1', + title: 'Secret', + isPublished: true, + hasPassword: true, + }, + }) + + await expect(service.getPublicNarration('1')).resolves.toBeNull() + expect(repository.findByRefAndLang).not.toHaveBeenCalled() + }) + + it('returns null for a password-protected note when the password is wrong', async () => { + const { databaseService, repository, service } = createHarness({ + storedNotePassword: 'letmein', + }) + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Note, + document: { + id: '1', + title: 'Secret', + isPublished: true, + hasPassword: true, + }, + }) + + await expect( + service.getPublicNarration('1', undefined, { password: 'nope' }), + ).resolves.toBeNull() + expect(repository.findByRefAndLang).not.toHaveBeenCalled() + }) + + it('serves a password-protected note to a reader who supplies the password', async () => { + const { databaseService, repository, service } = createHarness({ + storedNotePassword: 'letmein', + }) + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Note, + document: { + id: '1', + title: 'Secret', + isPublished: true, + hasPassword: true, + }, + }) + repository.findByRefAndLang.mockResolvedValue(parentRow()) + repository.findBlocks.mockResolvedValue([blockRow('blk-a')]) + + await expect( + service.getPublicNarration('1', undefined, { password: 'letmein' }), + ).resolves.not.toBeNull() + }) + + it('returns null for a future-dated note even when the password is correct', async () => { + const { databaseService, repository, service } = createHarness({ + storedNotePassword: 'letmein', + }) + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Note, + document: { + id: '1', + title: 'Scheduled and locked', + isPublished: true, + hasPassword: true, + publicAt: new Date(Date.now() + 86_400_000), + }, + }) + + await expect( + service.getPublicNarration('1', undefined, { password: 'letmein' }), + ).resolves.toBeNull() + expect(repository.findByRefAndLang).not.toHaveBeenCalled() + }) + + it('returns null for a future-dated secret note viewed anonymously', async () => { + const { databaseService, repository, service } = createHarness() + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Note, + document: { + id: '1', + title: 'Scheduled', + isPublished: true, + publicAt: new Date(Date.now() + 86_400_000), + }, + }) + + await expect(service.getPublicNarration('1')).resolves.toBeNull() + expect(repository.findByRefAndLang).not.toHaveBeenCalled() + }) + + it('serves a future-dated secret note to the owner', async () => { + const { databaseService, repository, service } = createHarness() + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Note, + document: { + id: '1', + title: 'Scheduled', + isPublished: true, + publicAt: new Date(Date.now() + 86_400_000), + }, + }) + repository.findByRefAndLang.mockResolvedValue(parentRow()) + repository.findBlocks.mockResolvedValue([blockRow('blk-a')]) + + await expect( + service.getPublicNarration('1', undefined, { isOwner: true }), + ).resolves.not.toBeNull() + }) + + it('returns null for a premium post an anonymous reader is not entitled to', async () => { + const { databaseService, repository, service } = createHarness() + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Post, + document: { + id: '1', + title: 'Premium', + isPublished: true, + isPremium: true, + }, + }) + + await expect(service.getPublicNarration('1')).resolves.toBeNull() + expect(repository.findByRefAndLang).not.toHaveBeenCalled() + }) + + it('serves a premium post to an entitled reader', async () => { + const { databaseService, entitlementService, repository, service } = + createHarness() + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Post, + document: { + id: '1', + title: 'Premium', + isPublished: true, + isPremium: true, + meta: { lang: 'zh' }, + }, + }) + repository.findByRefAndLang.mockResolvedValue(parentRow()) + repository.findBlocks.mockResolvedValue([blockRow('blk-a')]) + + await expect( + service.getPublicNarration('1', undefined, { readerId: 'reader-1' }), + ).resolves.not.toBeNull() + expect(entitlementService.isPremiumLocked).toHaveBeenCalledWith({ + isPremium: true, + isOwner: false, + readerId: 'reader-1', + }) + }) + + it('serves a premium post to the owner', async () => { + const { databaseService, repository, service } = createHarness() + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Post, + document: { + id: '1', + title: 'Premium', + isPublished: true, + isPremium: true, + meta: { lang: 'zh' }, + }, + }) + repository.findByRefAndLang.mockResolvedValue(parentRow()) + repository.findBlocks.mockResolvedValue([blockRow('blk-a')]) + + await expect( + service.getPublicNarration('1', undefined, { isOwner: true }), + ).resolves.not.toBeNull() + }) + + it('returns null when the parent exists but block_order was never published', async () => { + const { databaseService, repository, service } = createHarness() + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Post, + document: { + id: '1', + title: 'Post', + isPublished: true, + meta: { lang: 'zh' }, + }, + }) + repository.findByRefAndLang.mockResolvedValue(parentRow({ blockOrder: [] })) + + await expect(service.getPublicNarration('1')).resolves.toBeNull() + expect(repository.findBlocks).not.toHaveBeenCalled() + }) + + it('does not lock a premium note (premium only applies to posts)', async () => { + const { databaseService, repository, service } = createHarness() + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Note, + document: { id: '1', title: 'Note', isPublished: true, isPremium: true }, + }) + repository.findByRefAndLang.mockResolvedValue(parentRow()) + repository.findBlocks.mockResolvedValue([blockRow('blk-a')]) + + await expect(service.getPublicNarration('1')).resolves.not.toBeNull() + }) + + it('returns null when no narration exists yet for the resolved language', async () => { + const { databaseService, repository, service } = createHarness() + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Post, + document: { + id: '1', + title: 'Post', + isPublished: true, + meta: { lang: 'zh' }, + }, + }) + repository.findByRefAndLang.mockResolvedValue(null) + + await expect(service.getPublicNarration('1')).resolves.toBeNull() + expect(repository.findBlocks).not.toHaveBeenCalled() + }) + + it('defaults to the article source language when none is requested', async () => { + const { databaseService, repository, service } = createHarness() + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Post, + document: { + id: '1', + title: 'Post', + isPublished: true, + meta: { lang: 'en-US' }, + }, + }) + repository.findByRefAndLang.mockResolvedValue(parentRow({ lang: 'en' })) + repository.findBlocks.mockResolvedValue([blockRow('blk-a')]) + + await service.getPublicNarration('1') + + expect(repository.findByRefAndLang).toHaveBeenCalledWith('1', 'en') + }) + + it('uses the requested language over the article default', async () => { + const { databaseService, repository, service } = createHarness() + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Post, + document: { + id: '1', + title: 'Post', + isPublished: true, + meta: { lang: 'zh' }, + }, + }) + repository.findByRefAndLang.mockResolvedValue(parentRow({ lang: 'en' })) + repository.findBlocks.mockResolvedValue([]) + + await service.getPublicNarration('1', 'en') + + expect(repository.findByRefAndLang).toHaveBeenCalledWith('1', 'en') + }) + + it('returns the public narration shape with segments in order', async () => { + const { databaseService, repository, service } = createHarness() + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Post, + document: { + id: '1', + title: 'Post', + isPublished: true, + meta: { lang: 'zh' }, + }, + }) + repository.findByRefAndLang.mockResolvedValue(parentRow()) + repository.findBlocks.mockResolvedValue([ + blockRow('blk-a', { chunkIndex: 0, text: 'first' }), + blockRow('blk-b', { chunkIndex: 1, text: 'second' }), + ]) + + await expect(service.getPublicNarration('1')).resolves.toEqual({ + lang: 'zh', + model: 'gpt-4o-mini-tts', + voice: 'alloy', + blockOrder: ['blk-a', 'blk-b'], + segments: [ + { + blockId: 'blk-a', + chunkIndex: 0, + text: 'first', + url: 'https://cdn.example.com/blk-a.mp3', + }, + { + blockId: 'blk-b', + chunkIndex: 1, + text: 'second', + url: 'https://cdn.example.com/blk-b.mp3', + }, + ], + }) + }) + + it('orders segments by blockOrder even when the rows arrive scrambled', async () => { + const { databaseService, repository, service } = createHarness() + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Post, + document: { + id: '1', + title: 'Post', + isPublished: true, + meta: { lang: 'zh' }, + }, + }) + repository.findByRefAndLang.mockResolvedValue( + parentRow({ blockOrder: ['blk-c', 'blk-a', 'blk-b'] }), + ) + repository.findBlocks.mockResolvedValue([ + blockRow('blk-a', { chunkIndex: 0 }), + blockRow('blk-b', { chunkIndex: 0 }), + blockRow('blk-c', { chunkIndex: 1, id: 'row-blk-c-1' }), + blockRow('blk-c', { chunkIndex: 0 }), + ]) + + const result = await service.getPublicNarration('1') + + expect(result!.segments.map((s) => [s.blockId, s.chunkIndex])).toEqual([ + ['blk-c', 0], + ['blk-c', 1], + ['blk-a', 0], + ['blk-b', 0], + ]) + }) +}) + +describe('AiTtsQueryService.getDetailsByRefId', () => { + it('returns an empty array when the ref has no narrations', async () => { + const { repository, service } = createHarness() + repository.findAllByRef.mockResolvedValue([]) + + await expect(service.getDetailsByRefId('1')).resolves.toEqual([]) + expect(repository.findBlocksByTtsIds).not.toHaveBeenCalled() + }) + + it('loads blocks for every narration language of the ref', async () => { + const { repository, service } = createHarness() + repository.findAllByRef.mockResolvedValue([ + parentRow({ id: 'tts-1', lang: 'zh' }), + parentRow({ id: 'tts-2', lang: 'en' }), + ]) + repository.findBlocksByTtsIds.mockResolvedValue([ + blockRow('blk-a', { ttsId: 'tts-1' }), + blockRow('blk-b', { ttsId: 'tts-2' }), + ]) + + const result = await service.getDetailsByRefId('1') + + expect(repository.findBlocksByTtsIds).toHaveBeenCalledWith([ + 'tts-1', + 'tts-2', + ]) + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ id: 'tts-1', refId: '1', lang: 'zh' }) + expect(result[0].segments).toEqual([ + expect.objectContaining({ blockId: 'blk-a' }), + ]) + expect(result[1]).toMatchObject({ id: 'tts-2', lang: 'en' }) + expect(result[1].segments).toEqual([ + expect.objectContaining({ blockId: 'blk-b' }), + ]) + }) +}) + +describe('AiTtsQueryService.getNarrationsByRefId', () => { + it('returns the article together with the narration rows', async () => { + const { databaseService, repository, service } = createHarness() + databaseService.findGlobalById.mockResolvedValue({ + type: CollectionRefTypes.Post, + document: { id: '1', title: 'Hello world' }, + }) + repository.findAllByRef.mockResolvedValue([parentRow()]) + repository.findBlocksByTtsIds.mockResolvedValue([blockRow('blk-a')]) + + const result = await service.getNarrationsByRefId('1') + + expect(result.article).toMatchObject({ type: CollectionRefTypes.Post }) + expect(result.rows).toHaveLength(1) + expect(result.rows[0]).toMatchObject({ id: 'tts-1', refId: '1' }) + }) + + it('tolerates a missing article instead of failing', async () => { + const { databaseService, repository, service } = createHarness() + databaseService.findGlobalById.mockResolvedValue(null) + repository.findAllByRef.mockResolvedValue([parentRow()]) + repository.findBlocksByTtsIds.mockResolvedValue([]) + + const result = await service.getNarrationsByRefId('1') + + expect(result.article).toBeNull() + expect(result.rows).toHaveLength(1) + }) +}) + +describe('AiTtsQueryService.getAllNarrationsGrouped', () => { + it('includes orphan articles with zero narrations in the grouped list', async () => { + const { databaseService, repository, service } = createHarness() + repository.groupedByRef.mockResolvedValue({ + data: [{ refId: 'post-1' }], + pagination: { total: 1 }, + } as any) + repository.findDistinctRefIds.mockResolvedValue(['post-1']) + repository.listByRefIds.mockResolvedValue([ + parentRow({ id: 'tts-1', refId: 'post-1' }), + ]) + repository.findBlocksByTtsIds.mockResolvedValue([ + blockRow('blk-a', { ttsId: 'tts-1' }), + ]) + databaseService.findAllArticlesForAIText.mockResolvedValue({ + posts: [ + { id: 'post-1', title: 'Has Narration' }, + { id: 'post-2', title: 'Orphan Post' }, + ], + notes: [], + }) + databaseService.getRefArticleMap.mockResolvedValue({ + 'post-1': { + id: 'post-1', + title: 'Has Narration', + type: CollectionRefTypes.Post, + }, + }) + + const result = await service.getAllNarrationsGrouped({ page: 1, size: 10 }) + + expect(result.pagination).toMatchObject({ total: 2, currentPage: 1 }) + expect(result.data).toEqual([ + { + article: { + id: 'post-1', + title: 'Has Narration', + type: CollectionRefTypes.Post, + }, + narrations: [expect.objectContaining({ id: 'tts-1', refId: 'post-1' })], + }, + { + article: { + id: 'post-2', + title: 'Orphan Post', + type: CollectionRefTypes.Post, + }, + narrations: [], + }, + ]) + }) +}) + +describe('AiTtsQueryService.list', () => { + it('derives blockCount from blockOrder length', async () => { + const { repository, service } = createHarness() + repository.listPaginated.mockResolvedValue({ + data: [parentRow({ blockOrder: ['blk-a', 'blk-b', 'blk-c'] })], + pagination: { + currentPage: 1, + totalPage: 1, + total: 1, + size: 10, + hasNextPage: false, + hasPrevPage: false, + }, + }) + + const result = await service.list({ page: 1, size: 10 }) + + expect(repository.listPaginated).toHaveBeenCalledWith({ + page: 1, + size: 10, + }) + expect(result.data).toEqual([ + { + id: 'tts-1', + refId: '1', + lang: 'zh', + blockCount: 3, + charCount: 20, + updatedAt: now, + }, + ]) + expect(result.pagination.total).toBe(1) + }) + + it('resolves article titles for the rows via the ref article map', async () => { + const { databaseService, repository, service } = createHarness() + repository.listPaginated.mockResolvedValue({ + data: [parentRow({ refId: '1' })], + pagination: { + currentPage: 1, + totalPage: 1, + total: 1, + size: 10, + hasNextPage: false, + hasPrevPage: false, + }, + }) + databaseService.getRefArticleMap.mockResolvedValue({ + '1': { id: '1', title: 'Hello world', type: 'Post' }, + }) + + const result = await service.list({ page: 1, size: 10 }) + + expect(databaseService.getRefArticleMap).toHaveBeenCalledWith(['1']) + expect(result.articles).toEqual({ + '1': { id: '1', title: 'Hello world', type: 'Post' }, + }) + }) +}) diff --git a/apps/core/test/src/modules/ai/ai-tts/ai-tts.controller.spec.ts b/apps/core/test/src/modules/ai/ai-tts/ai-tts.controller.spec.ts new file mode 100644 index 00000000000..1d96330b24c --- /dev/null +++ b/apps/core/test/src/modules/ai/ai-tts/ai-tts.controller.spec.ts @@ -0,0 +1,257 @@ +import { describe, expect, it, vi } from 'vitest' + +import { AiTtsController } from '~/modules/ai/ai-tts/ai-tts.controller' + +function createHarness() { + const service: any = { + deleteById: vi.fn(), + } + const queryService: any = { + getPublicNarration: vi.fn(), + getDetailsByRefId: vi.fn(), + getNarrationsByRefId: vi.fn(), + getAllNarrationsGrouped: vi.fn(), + list: vi.fn(), + } + const taskService: any = { + createTtsTask: vi.fn(async () => ({ taskId: 'task-1', created: true })), + } + const controller = new AiTtsController(service, queryService, taskService) + return { controller, service, queryService, taskService } +} + +describe('AiTtsController', () => { + it('returns null for an article with no narration', async () => { + const { controller, queryService } = createHarness() + queryService.getPublicNarration.mockResolvedValue(null) + + await expect( + controller.getArticleTts({ id: '1' } as any, {} as any), + ).resolves.toBeNull() + }) + + it('returns null for an unpublished article', async () => { + const { controller, queryService } = createHarness() + queryService.getPublicNarration.mockResolvedValue(null) + + expect( + await controller.getArticleTts({ id: 'draft' } as any, {} as any), + ).toBeNull() + }) + + it('returns null for a locked premium article', async () => { + const { controller, queryService } = createHarness() + queryService.getPublicNarration.mockResolvedValue(null) + + expect( + await controller.getArticleTts({ id: 'premium' } as any, {} as any), + ).toBeNull() + }) + + it('parses a found narration through the public view', async () => { + const { controller, queryService } = createHarness() + queryService.getPublicNarration.mockResolvedValue({ + lang: 'zh', + model: 'gpt-4o-mini-tts', + voice: 'alloy', + blockOrder: ['blk-a'], + segments: [ + { + blockId: 'blk-a', + chunkIndex: 0, + text: 'hello', + url: 'https://x/a.mp3', + }, + ], + }) + + const result = await controller.getArticleTts({ id: '1' } as any, {} as any) + + expect(result).toMatchObject({ lang: 'zh', voice: 'alloy' }) + expect(queryService.getPublicNarration).toHaveBeenCalledWith( + '1', + undefined, + { + isOwner: false, + password: undefined, + readerId: undefined, + }, + ) + }) + + it('canonicalizes an explicit lang query param before delegating', async () => { + const { controller, queryService } = createHarness() + queryService.getPublicNarration.mockResolvedValue(null) + + await controller.getArticleTts({ id: '1' } as any, { lang: 'zh-CN' } as any) + + expect(queryService.getPublicNarration).toHaveBeenCalledWith('1', 'zh', { + isOwner: false, + password: undefined, + readerId: undefined, + }) + }) + + it('forwards owner access, reader identity and the note password to the query service', async () => { + const { controller, queryService } = createHarness() + queryService.getPublicNarration.mockResolvedValue(null) + + await controller.getArticleTts( + { id: '1' } as any, + { password: 'letmein' } as any, + true, + 'reader-1', + ) + + expect(queryService.getPublicNarration).toHaveBeenCalledWith( + '1', + undefined, + { + isOwner: true, + password: 'letmein', + readerId: 'reader-1', + }, + ) + }) + + it('enqueues a task with the canonical language list', async () => { + const { controller, taskService } = createHarness() + + await controller.createTask({ refId: '1', langs: ['zh-CN', 'zh'] } as any) + + expect(taskService.createTtsTask).toHaveBeenCalledWith( + expect.objectContaining({ refId: '1', langs: ['zh'] }), + ) + }) + + it('omits langs from the task payload when none are requested', async () => { + const { controller, taskService } = createHarness() + + await controller.createTask({ refId: '1' } as any) + + expect(taskService.createTtsTask).toHaveBeenCalledWith( + expect.objectContaining({ refId: '1', langs: undefined }), + ) + }) + + it('lists narrations wrapped with pagination and article meta', async () => { + const { controller, queryService } = createHarness() + queryService.list.mockResolvedValue({ + data: [ + { + id: 'tts-1', + refId: '1', + lang: 'zh', + blockCount: 2, + charCount: 20, + updatedAt: new Date('2026-01-01'), + }, + ], + pagination: { + currentPage: 1, + totalPage: 1, + total: 1, + size: 10, + hasNextPage: false, + hasPrevPage: false, + }, + articles: { '1': { id: '1', title: 'Hello world', type: 'Post' } }, + }) + + const result = await controller.list({ page: 1, size: 10 } as any) + + expect(result.data).toHaveLength(1) + expect(result.meta.pagination).toMatchObject({ total: 1, page: 1 }) + expect(result.meta.articles).toEqual({ + '1': { id: '1', title: 'Hello world', type: 'Post' }, + }) + }) + + it('returns narration details for a ref together with the article', async () => { + const { controller, queryService } = createHarness() + queryService.getNarrationsByRefId.mockResolvedValue({ + article: { + type: 'Post', + document: { id: '1', title: 'Hello world' }, + }, + rows: [ + { + id: 'tts-1', + refId: '1', + lang: 'zh', + isTranslation: false, + model: 'gpt-4o-mini-tts', + voice: 'alloy', + speed: 1, + blockOrder: ['blk-a'], + charCount: 10, + updatedAt: null, + segments: [], + }, + ], + }) + + const result = await controller.getByRefId({ id: '1' } as any) + + expect(queryService.getNarrationsByRefId).toHaveBeenCalledWith('1') + expect(result.article).toMatchObject({ type: 'Post' }) + expect(result.rows).toHaveLength(1) + expect(result.rows[0]).toMatchObject({ id: 'tts-1', refId: '1' }) + }) + + it('wraps grouped narrations with pagination meta', async () => { + const { controller, queryService } = createHarness() + queryService.getAllNarrationsGrouped.mockResolvedValue({ + data: [ + { + article: { id: '1', title: 'Hello world', type: 'Post' }, + narrations: [ + { + id: 'tts-1', + refId: '1', + lang: 'zh', + isTranslation: false, + model: 'gpt-4o-mini-tts', + voice: 'alloy', + speed: 1, + blockOrder: ['blk-a'], + charCount: 10, + updatedAt: null, + segments: [], + }, + ], + }, + ], + pagination: { + currentPage: 1, + totalPage: 1, + total: 1, + size: 10, + hasNextPage: false, + hasPrevPage: false, + }, + }) + + const result = await controller.listGrouped({ page: 1, size: 10 } as any) + + expect(queryService.getAllNarrationsGrouped).toHaveBeenCalledWith({ + page: 1, + size: 10, + }) + expect(result.data).toHaveLength(1) + expect(result.data[0].article).toMatchObject({ id: '1' }) + expect(result.data[0].narrations[0]).toMatchObject({ + id: 'tts-1', + refId: '1', + }) + expect(result.meta.pagination).toMatchObject({ total: 1, page: 1 }) + }) + + it('delegates delete to the service', async () => { + const { controller, service } = createHarness() + + await controller.delete({ id: 'tts-1' } as any) + + expect(service.deleteById).toHaveBeenCalledWith('tts-1') + }) +}) diff --git a/apps/core/test/src/modules/ai/ai-tts/ai-tts.faux.e2e.spec.ts b/apps/core/test/src/modules/ai/ai-tts/ai-tts.faux.e2e.spec.ts new file mode 100644 index 00000000000..65b4a72f3b4 --- /dev/null +++ b/apps/core/test/src/modules/ai/ai-tts/ai-tts.faux.e2e.spec.ts @@ -0,0 +1,677 @@ +import { createPgRepositoryMock, now } from 'test/helper/pg-repository-mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { AppErrorCode, createAppException } from '~/common/errors' +import { CollectionRefTypes } from '~/constants/db.constant' +import type { TtsTaskPayload } from '~/modules/ai/ai-task/ai-task.types' +import { toArticleContent } from '~/modules/ai/ai-translation/article-content.util' +import type { AiTtsRepository } from '~/modules/ai/ai-tts/ai-tts.repository' +import { AiTtsService } from '~/modules/ai/ai-tts/ai-tts.service' +import { computeSpeechFingerprint } from '~/modules/ai/ai-tts/tts-block-plan' +import { + buildTtsObjectKey, + computeTtsObjectFingerprint, +} from '~/modules/ai/ai-tts/tts-object-key' +import type { TaskExecuteContext } from '~/processors/task-queue' +import { TaskStatus } from '~/processors/task-queue' +import { computeContentHash } from '~/utils/content.util' + +const { generateSpeechMock } = vi.hoisted(() => ({ + generateSpeechMock: vi.fn(), +})) + +vi.mock('~/modules/ai/ai-tts/tts-runtime.adapter', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('~/modules/ai/ai-tts/tts-runtime.adapter') + >() + return { + ...actual, + TtsRuntimeAdapter: vi.fn(function (this: { + generateSpeech: typeof generateSpeechMock + }) { + this.generateSpeech = generateSpeechMock + }), + } +}) + +const TEXT_A = 'first block text' +const TEXT_B = 'second block text' +const FP_A = computeSpeechFingerprint('paragraph', TEXT_A) +const FP_B = computeSpeechFingerprint('paragraph', TEXT_B) + +const paragraph = (text: string) => ({ + type: 'paragraph', + children: [{ type: 'text', text }], +}) + +const article = (modifiedAt = new Date('2026-01-01')) => ({ + type: CollectionRefTypes.Post, + document: { + id: '1', + title: 'Narratable post', + text: 'plain text', + contentFormat: 'lexical', + content: '{"root":{"children":[]}}', + meta: { lang: 'zh-CN' }, + modifiedAt, + }, +}) + +// Mirrors how ai-translation.service.ts writes a row: the hash is computed with +// the very string that is then stored in sourceLang, so the fixture can never +// express a pairing the writer could not produce. +const translationRow = (overrides: Record = {}) => { + const sourceLang = (overrides.sourceLang as string) ?? 'zh' + return { + contentFormat: 'lexical', + content: '{"root":{"children":[]}}', + sourceLang, + hash: computeContentHash( + toArticleContent(article().document as never), + sourceLang, + ), + sourceModifiedAt: new Date('2026-01-01'), + createdAt: now, + ...overrides, + } +} + +const PUBLISHED_VOICE = { + model: 'published-model', + voice: 'published-voice', + speed: 1, +} + +const objectKeyUnderVoice = ( + blockId: string, + fingerprint: string, + voice = PUBLISHED_VOICE, +) => + buildTtsObjectKey({ + refId: '1', + lang: 'zh', + blockId, + chunkIndex: 0, + fingerprint: computeTtsObjectFingerprint(fingerprint, voice), + }) + +const blockRow = ( + blockId: string, + fingerprint: string, + overrides: Record = {}, +) => ({ + id: `row-${blockId}`, + createdAt: now, + ttsId: 'tts-1', + blockId, + fingerprint, + chunkIndex: 0, + text: 'previously narrated', + url: `https://cdn.example.com/${blockId}.mp3`, + storageBackend: 's3' as const, + storageKey: objectKeyUnderVoice(blockId, fingerprint), + byteSize: 1, + durationMs: null, + ...overrides, +}) + +const parentRow = (overrides: Record = {}) => ({ + id: 'tts-1', + createdAt: now, + updatedAt: now, + refId: '1', + lang: 'zh', + isTranslation: false, + sourceLang: null, + ...PUBLISHED_VOICE, + format: 'mp3', + blockOrder: ['blk-a', 'blk-b'], + charCount: 10, + totalDurationMs: null, + sourceModifiedAt: new Date('2026-01-01'), + ...overrides, +}) + +function baseTtsConfig() { + return { + enable: true, + provider: 'openrouter', + apiKey: 'test-api-key', + endpoint: '', + model: 'openai/gpt-4o-mini-tts', + voice: 'alloy', + speed: 1, + maxCharsPerChunk: 1800, + concurrency: 3, + maxCharsPerRun: 120_000, + } +} + +function createTaskContext( + overrides: Partial = {}, +): TaskExecuteContext { + return { + taskId: 'task-tts', + signal: new AbortController().signal, + updateProgress: vi.fn(), + incrementTokens: vi.fn(), + incrementCost: vi.fn(), + appendLog: vi.fn(), + setResult: vi.fn(), + setStatus: vi.fn(), + isAborted: () => false, + streamPusher: vi.fn(), + ...overrides, + } +} + +function createHarness() { + const ttsConfig = baseTtsConfig() + const configService = { + get: vi.fn(async (key: string) => { + if (key === 'ttsOptions') return ttsConfig + if (key === 'imageStorageOptions') return { prefix: '' } + return {} + }), + } + + const fileService = { + uploadBuffer: vi.fn( + async (_buffer: Buffer, opts: { objectKey: string }) => ({ + url: `https://cdn.example.com/${opts.objectKey}`, + name: opts.objectKey.split('/').pop(), + storageBackend: 's3' as const, + storageKey: opts.objectKey, + }), + ), + resolveFileUrl: vi.fn( + async (type: string, name: string) => + `https://self.example.com/objects/${type}/${name}`, + ), + deleteObject: vi.fn(async () => {}), + } + + const repository = createPgRepositoryMock() + repository.findByRefAndLang.mockResolvedValue(null) + repository.findBlocks.mockResolvedValue([]) + repository.upsertParent.mockImplementation(async (input: any) => ({ + ...parentRow(), + ...input, + })) + repository.upsertBlock.mockImplementation(async (input: any) => ({ + ...blockRow(input.blockId, input.fingerprint), + ...input, + })) + repository.deleteBlocksByIds.mockResolvedValue(undefined) + + const databaseService = { findGlobalById: vi.fn(async () => article()) } + + const lexicalService = { + extractRootBlockNodes: vi.fn(() => [ + { id: 'blk-a', type: 'paragraph', node: paragraph(TEXT_A), index: 0 }, + { id: 'blk-b', type: 'paragraph', node: paragraph(TEXT_B), index: 1 }, + ]), + } + + const translationRepository = { findByRefAndLang: vi.fn(async () => null) } + + const lockStore = new Map() + const redisClient = { + set: vi.fn(async (key: string, value: string) => { + if (lockStore.has(key)) return null + lockStore.set(key, value) + return 'OK' + }), + get: vi.fn(async (key: string) => lockStore.get(key) ?? null), + del: vi.fn(async (key: string) => (lockStore.delete(key) ? 1 : 0)), + expire: vi.fn(async () => 1), + eval: vi.fn( + async (script: string, _keys: number, key: string, token: string) => { + if (lockStore.get(key) !== token) return 0 + if (script.includes('del')) lockStore.delete(key) + return 1 + }, + ), + } + const redisService = { getClient: () => redisClient } + + let registered: + | { + execute: ( + payload: TtsTaskPayload, + context: TaskExecuteContext, + ) => Promise + } + | undefined + const taskProcessor = { + registerHandler: vi.fn((handler) => { + registered = handler + }), + } + + const service = new AiTtsService( + configService as any, + fileService as any, + taskProcessor as any, + repository as any, + databaseService as any, + lexicalService as any, + translationRepository as any, + redisService as any, + ) + service.onModuleInit() + + return { + ttsConfig, + fileService, + repository, + databaseService, + lexicalService, + translationRepository, + redis: redisClient, + locks: lockStore, + service, + execute: (payload: TtsTaskPayload, context: TaskExecuteContext) => + registered!.execute(payload, context), + } +} + +type Harness = ReturnType + +describe('ai-tts generation task (faux e2e)', () => { + let h: Harness + let context: TaskExecuteContext + + beforeEach(() => { + generateSpeechMock.mockReset() + generateSpeechMock.mockResolvedValue({ + buffer: Buffer.from('audio-bytes'), + mimeType: 'audio/mpeg', + }) + h = createHarness() + context = createTaskContext() + }) + + const publishedWith = (blocks: ReturnType[]) => { + h.repository.findByRefAndLang.mockResolvedValue(parentRow()) + h.repository.findBlocks.mockResolvedValue(blocks) + } + + it('generates every block on the first run and publishes block order', async () => { + await h.execute({ refId: '1' }, context) + + expect(generateSpeechMock).toHaveBeenCalledTimes(2) + expect(h.repository.upsertBlock).toHaveBeenCalledTimes(2) + expect(h.repository.upsertParent).toHaveBeenCalledWith( + expect.objectContaining({ blockOrder: ['blk-a', 'blk-b'] }), + ) + }) + + it('creates the parent with an empty block order before generating anything', async () => { + await h.execute({ refId: '1' }, context) + + expect(h.repository.upsertParent.mock.calls[0][0]).toMatchObject({ + blockOrder: [], + charCount: 0, + sourceModifiedAt: null, + }) + expect(h.repository.upsertParent.mock.invocationCallOrder[0]).toBeLessThan( + h.repository.upsertBlock.mock.invocationCallOrder[0], + ) + }) + + it('regenerates only the edited block on the second run', async () => { + publishedWith([blockRow('blk-a', FP_A), blockRow('blk-b', 'fp-b-old')]) + + await h.execute({ refId: '1' }, context) + + expect(generateSpeechMock).toHaveBeenCalledTimes(1) + expect(generateSpeechMock.mock.calls[0][0].input).toContain('second') + }) + + it('uploads audio with an explicit content-addressed object key', async () => { + await h.execute({ refId: '1' }, context) + + expect(h.fileService.uploadBuffer).toHaveBeenCalledTimes(2) + for (const [, opts] of h.fileService.uploadBuffer.mock.calls) { + expect(opts.type).toBe('audio') + expect(opts.objectKey).toMatch( + /^tts\/1\/zh\/blk-[ab]-0-[\da-f]{12}\.mp3$/, + ) + } + }) + + it('commits each chunk before the next one is generated', async () => { + h.ttsConfig.concurrency = 1 + const order: string[] = [] + generateSpeechMock.mockImplementation(async () => { + order.push('generate') + return { buffer: Buffer.from('a'), mimeType: 'audio/mpeg' } + }) + h.repository.upsertBlock.mockImplementation(async () => { + order.push('commit') + return {} as never + }) + + await h.execute({ refId: '1' }, context) + + expect(order).toEqual(['generate', 'commit', 'generate', 'commit']) + }) + + it('deletes displaced objects after the upsert, never before', async () => { + publishedWith([blockRow('gone', 'fp-x')]) + + await h.execute({ refId: '1' }, context) + + expect(h.repository.upsertBlock).toHaveBeenCalled() + expect(h.fileService.deleteObject).toHaveBeenCalledWith( + 's3', + objectKeyUnderVoice('gone', 'fp-x'), + ) + expect(h.repository.upsertBlock.mock.invocationCallOrder[0]).toBeLessThan( + h.fileService.deleteObject.mock.invocationCallOrder[0], + ) + }) + + it('deletes the object a regenerated chunk displaced, after its row is upserted', async () => { + publishedWith([blockRow('blk-a', 'fp-a-old'), blockRow('blk-b', FP_B)]) + + await h.execute({ refId: '1' }, context) + + expect(h.repository.deleteBlocksByIds).toHaveBeenCalledWith([]) + expect(h.fileService.deleteObject).toHaveBeenCalledWith( + 's3', + objectKeyUnderVoice('blk-a', 'fp-a-old'), + ) + expect(h.repository.upsertBlock.mock.invocationCallOrder[0]).toBeLessThan( + h.fileService.deleteObject.mock.invocationCallOrder[0], + ) + }) + + it('survives a failing object deletion', async () => { + publishedWith([blockRow('gone', 'fp-x')]) + h.fileService.deleteObject.mockRejectedValue(new Error('network')) + + await h.execute({ refId: '1' }, context) + + expect(context.setStatus).not.toHaveBeenCalled() + }) + + it('treats an already-written content-addressed object as a successful upload', async () => { + h.fileService.uploadBuffer.mockRejectedValue( + createAppException(AppErrorCode.FILE_EXISTS), + ) + + await h.execute({ refId: '1' }, context) + + expect(h.repository.upsertBlock).toHaveBeenCalledTimes(2) + expect(h.repository.upsertBlock).toHaveBeenCalledWith( + expect.objectContaining({ + storageBackend: 'local', + storageKey: expect.stringMatching(/^tts\/1\/zh\/blk-a-0-/), + url: expect.stringContaining('/objects/audio/tts/1/zh/blk-a-0-'), + }), + ) + expect(context.setStatus).not.toHaveBeenCalled() + }) + + it('reports progress as a percentage', async () => { + await h.execute({ refId: '1' }, context) + + const values = vi + .mocked(context.updateProgress) + .mock.calls.map((call) => call[0]) + expect(values.at(-1)).toBe(100) + expect(values.every((value) => value >= 0 && value <= 100)).toBe(true) + }) + + it('skips a language whose lock is already held', async () => { + h.redis.set.mockResolvedValue(null) + + await h.execute({ refId: '1', langs: ['zh'] }, context) + + expect(generateSpeechMock).not.toHaveBeenCalled() + expect(h.repository.upsertParent).not.toHaveBeenCalled() + expect(context.setStatus).not.toHaveBeenCalled() + }) + + it('releases the lock once the language finishes', async () => { + await h.execute({ refId: '1' }, context) + + expect(h.redis.set).toHaveBeenCalledWith( + 'ai:tts:lock:1:zh', + expect.any(String), + 'EX', + 300, + 'NX', + ) + expect(h.locks.size).toBe(0) + }) + + it('releases the lock when the language fails', async () => { + generateSpeechMock.mockRejectedValue(new Error('provider down')) + + await h.execute({ refId: '1' }, context) + + expect(context.setStatus).toHaveBeenCalledWith(TaskStatus.Failed) + expect(h.locks.size).toBe(0) + }) + + it('settles every in-flight chunk before releasing the lock', async () => { + h.ttsConfig.concurrency = 3 + let resolveSlow: (() => void) | undefined + const slow = new Promise((resolve) => { + resolveSlow = resolve + }) + generateSpeechMock + .mockRejectedValueOnce(new Error('provider down')) + .mockImplementationOnce(async () => { + await slow + return { buffer: Buffer.from('a'), mimeType: 'audio/mpeg' } + }) + + setTimeout(() => resolveSlow!(), 10) + await h.execute({ refId: '1' }, context) + + expect(h.repository.upsertBlock).toHaveBeenCalledTimes(1) + expect(h.locks.size).toBe(0) + }) + + it('skips the finalize when the article changed mid-run', async () => { + publishedWith([]) + h.databaseService.findGlobalById + .mockResolvedValueOnce(article(new Date('2026-01-01'))) + .mockResolvedValueOnce(article(new Date('2026-02-01'))) + + await h.execute({ refId: '1' }, context) + + expect(h.repository.upsertBlock).toHaveBeenCalled() + expect(h.repository.upsertParent).not.toHaveBeenCalled() + }) + + it('reports a mid-run source change as PartialFailed, never as a clean success', async () => { + publishedWith([]) + h.databaseService.findGlobalById + .mockResolvedValueOnce(article(new Date('2026-01-01'))) + .mockResolvedValueOnce(article(new Date('2026-02-01'))) + + await h.execute({ refId: '1' }, context) + + expect(context.setStatus).toHaveBeenCalledWith(TaskStatus.PartialFailed) + expect(vi.mocked(context.setResult).mock.calls[0][0]).toMatchObject({ + perLang: [expect.objectContaining({ requeued: true })], + }) + }) + + it('fails the language when the plan exceeds maxCharsPerRun', async () => { + h.ttsConfig.maxCharsPerRun = 1 + + await h.execute({ refId: '1' }, context) + + expect(context.setStatus).toHaveBeenCalledWith(TaskStatus.Failed) + expect(generateSpeechMock).not.toHaveBeenCalled() + }) + + it('sets PartialFailed when one of two languages fails', async () => { + await h.execute({ refId: '1', langs: ['zh', 'en'] }, context) + + expect(context.setStatus).toHaveBeenCalledWith(TaskStatus.PartialFailed) + }) + + it('narrates a translated language from its lexical translation row', async () => { + h.translationRepository.findByRefAndLang.mockResolvedValue(translationRow()) + + await h.execute({ refId: '1', langs: ['en'] }, context) + + expect(context.setStatus).not.toHaveBeenCalled() + expect(h.repository.upsertParent).toHaveBeenCalledWith( + expect.objectContaining({ isTranslation: true, sourceLang: 'zh' }), + ) + }) + + it('narrates a fresh translation whose sourceLang carries a region subtag', async () => { + h.translationRepository.findByRefAndLang.mockResolvedValue( + translationRow({ sourceLang: 'zh-CN' }), + ) + + await h.execute({ refId: '1', langs: ['en'] }, context) + + expect(generateSpeechMock).toHaveBeenCalled() + expect(context.setStatus).not.toHaveBeenCalled() + }) + + it('refuses to narrate a translation whose hash no longer matches the article', async () => { + h.translationRepository.findByRefAndLang.mockResolvedValue( + translationRow({ hash: 'hash-of-an-older-article' }), + ) + + await h.execute({ refId: '1', langs: ['en'] }, context) + + expect(generateSpeechMock).not.toHaveBeenCalled() + expect(context.setStatus).toHaveBeenCalledWith(TaskStatus.Failed) + }) + + it('stamps a translated language with the translation vintage, not the article mtime', async () => { + h.translationRepository.findByRefAndLang.mockResolvedValue( + translationRow({ sourceModifiedAt: new Date('2025-06-01') }), + ) + + await h.execute({ refId: '1', langs: ['en'] }, context) + + expect(h.repository.upsertParent).toHaveBeenLastCalledWith( + expect.objectContaining({ sourceModifiedAt: new Date('2025-06-01') }), + ) + }) + + it('canonicalizes and deduplicates the requested languages', async () => { + await h.execute({ refId: '1', langs: ['zh-CN', 'zh'] }, context) + + expect(h.redis.set).toHaveBeenCalledTimes(1) + expect(h.repository.upsertParent).toHaveBeenCalledWith( + expect.objectContaining({ lang: 'zh' }), + ) + }) + + it('rejects a task carrying more than eight languages', async () => { + await expect( + h.execute( + { + refId: '1', + langs: ['en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'ru', 'pt'], + }, + context, + ), + ).rejects.toMatchObject({ code: AppErrorCode.AI_INVALID_PARAMETER }) + }) + + it('throws TTS_DISABLED when the feature is off', async () => { + h.ttsConfig.enable = false + + await expect(h.execute({ refId: '1' }, context)).rejects.toMatchObject({ + code: AppErrorCode.TTS_DISABLED, + }) + }) + + it('throws TTS_PROVIDER_NOT_CONFIGURED without an api key', async () => { + h.ttsConfig.apiKey = '' + + await expect(h.execute({ refId: '1' }, context)).rejects.toMatchObject({ + code: AppErrorCode.TTS_PROVIDER_NOT_CONFIGURED, + }) + }) + + it('pins the published voice config on an incremental run', async () => { + publishedWith([blockRow('blk-a', 'fp-a-old'), blockRow('blk-b', FP_B)]) + + await h.execute({ refId: '1' }, context) + + expect(generateSpeechMock.mock.calls[0][0].voice).toBe('published-voice') + expect(h.repository.upsertParent).toHaveBeenCalledWith( + expect.objectContaining({ model: 'published-model' }), + ) + }) + + it('regenerates a row whose object was written under another voice by a crashed force run', async () => { + publishedWith([ + blockRow('blk-a', FP_A, { + storageKey: objectKeyUnderVoice('blk-a', FP_A, { + model: 'published-model', + voice: 'nova', + speed: 1, + }), + }), + blockRow('blk-b', FP_B), + ]) + + await h.execute({ refId: '1' }, context) + + expect(generateSpeechMock).toHaveBeenCalledTimes(1) + expect(generateSpeechMock.mock.calls[0][0].input).toContain('first') + expect(generateSpeechMock.mock.calls[0][0].voice).toBe('published-voice') + expect(h.repository.upsertBlock).toHaveBeenCalledWith( + expect.objectContaining({ + blockId: 'blk-a', + storageKey: objectKeyUnderVoice('blk-a', FP_A), + }), + ) + }) + + it('takes the current global voice config when forced', async () => { + publishedWith([blockRow('blk-a', FP_A), blockRow('blk-b', FP_B)]) + + await h.execute({ refId: '1', force: true }, context) + + expect(generateSpeechMock).toHaveBeenCalledTimes(2) + expect(generateSpeechMock.mock.calls[0][0].voice).toBe('alloy') + }) + + it('a force run with a changed voice writes new object keys and displaces the old audio', async () => { + await h.execute({ refId: '1' }, context) + const alloyKeys = h.repository.upsertBlock.mock.calls.map( + ([input]) => input.storageKey, + ) + expect(alloyKeys).toHaveLength(2) + + const revoiced = createHarness() + revoiced.ttsConfig.voice = 'nova' + revoiced.repository.findByRefAndLang.mockResolvedValue( + parentRow({ voice: 'alloy' }), + ) + revoiced.repository.findBlocks.mockResolvedValue([ + blockRow('blk-a', FP_A, { storageKey: alloyKeys[0] }), + blockRow('blk-b', FP_B, { storageKey: alloyKeys[1] }), + ]) + + await revoiced.execute({ refId: '1', force: true }, createTaskContext()) + + const novaKeys = revoiced.repository.upsertBlock.mock.calls.map( + ([input]) => input.storageKey, + ) + expect(novaKeys).toHaveLength(2) + for (const key of novaKeys) expect(alloyKeys).not.toContain(key) + for (const key of alloyKeys) { + expect(revoiced.fileService.deleteObject).toHaveBeenCalledWith('s3', key) + } + }) +}) diff --git a/apps/core/test/src/modules/ai/ai-tts/ai-tts.repository.pg.e2e.spec.ts b/apps/core/test/src/modules/ai/ai-tts/ai-tts.repository.pg.e2e.spec.ts new file mode 100644 index 00000000000..08c2c5d35f4 --- /dev/null +++ b/apps/core/test/src/modules/ai/ai-tts/ai-tts.repository.pg.e2e.spec.ts @@ -0,0 +1,326 @@ +import { drizzle } from 'drizzle-orm/node-postgres' +import { Pool } from 'pg' +import { createIsolatedPgDatabase } from 'test/helper/pg-testcontainer' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { AiTtsRepository } from '~/modules/ai/ai-tts/ai-tts.repository' +import type { AiTtsRow } from '~/modules/ai/ai-tts/ai-tts.types' +import type { AppDatabase } from '~/processors/database/postgres.provider' +import { SnowflakeService } from '~/shared/id/snowflake.service' + +describe('ai-tts repository (real PG, ON CONFLICT + cascade)', () => { + let pool: Pool + let database: Awaited> + let db: AppDatabase + let repository: AiTtsRepository + + beforeAll(async () => { + database = await createIsolatedPgDatabase() + pool = new Pool({ connectionString: database.getConnectionUri() }) + db = drizzle(pool) as unknown as AppDatabase + const snowflake = new SnowflakeService() + repository = new AiTtsRepository(db, snowflake) + }, 120_000) + + afterAll(async () => { + await pool?.end() + await database?.drop() + }) + + let parent: AiTtsRow + + it('upserts a parent row and replaces a block in place', async () => { + parent = await repository.upsertParent({ + refId: '1', + lang: 'zh', + isTranslation: false, + sourceLang: null, + model: 'm', + voice: 'v', + speed: 1, + format: 'mp3', + blockOrder: ['a'], + charCount: 5, + sourceModifiedAt: new Date(), + }) + expect(parent.refId).toBe('1') + expect(parent.lang).toBe('zh') + + await repository.upsertBlock({ + ttsId: parent.id, + blockId: 'a', + chunkIndex: 0, + fingerprint: 'fp1', + text: 'hello', + url: 'https://cdn/x1.mp3', + storageBackend: 's3', + storageKey: 'k/x1', + byteSize: 10, + }) + await repository.upsertBlock({ + ttsId: parent.id, + blockId: 'a', + chunkIndex: 0, + fingerprint: 'fp2', + text: 'hello there', + url: 'https://cdn/x2.mp3', + storageBackend: 's3', + storageKey: 'k/x2', + byteSize: 12, + }) + + const blocks = await repository.findBlocks(parent.id) + expect(blocks).toHaveLength(1) + expect(blocks[0].fingerprint).toBe('fp2') + expect(blocks[0].storageKey).toBe('k/x2') + }) + + it('upserting a parent row again for the same (refId, lang) replaces it in place', async () => { + const updated = await repository.upsertParent({ + refId: '1', + lang: 'zh', + isTranslation: false, + sourceLang: null, + model: 'm2', + voice: 'v2', + speed: 1.2, + format: 'mp3', + blockOrder: ['a', 'b'], + charCount: 11, + sourceModifiedAt: new Date(), + }) + expect(updated.id).toBe(parent.id) + expect(updated.model).toBe('m2') + expect(updated.blockOrder).toEqual(['a', 'b']) + + const all = await repository.findAllByRef('1') + expect(all).toHaveLength(1) + }) + + it('findMeta reports the block count from block_order', async () => { + const meta = await repository.findMeta('1', 'zh') + expect(meta?.id).toBe(parent.id) + expect(meta?.blockCount).toBe(2) + }) + + it('findMeta returns null when no row matches', async () => { + expect(await repository.findMeta('999999', 'zh')).toBeNull() + }) + + it('cascades block deletion when the parent is deleted, returning every removed block', async () => { + await repository.upsertBlock({ + ttsId: parent.id, + blockId: 'b', + chunkIndex: 0, + fingerprint: 'fp-b', + text: 'more', + url: 'https://cdn/b.mp3', + storageBackend: 's3', + storageKey: 'k/b', + }) + + const removed = await repository.deleteById(parent.id) + expect(removed.map((b) => b.storageKey).sort()).toEqual(['k/b', 'k/x2']) + expect(await repository.findBlocks(parent.id)).toEqual([]) + expect(await repository.findByRefAndLang('1', 'zh')).toBeNull() + }) + + it('findByRefAndLang returns null when nothing exists for that language', async () => { + await repository.upsertParent({ + refId: '2', + lang: 'en', + isTranslation: false, + sourceLang: null, + model: 'm', + voice: 'v', + speed: 1, + format: 'mp3', + blockOrder: [], + charCount: 0, + sourceModifiedAt: null, + }) + + expect(await repository.findByRefAndLang('2', 'ja')).toBeNull() + const found = await repository.findByRefAndLang('2', 'en') + expect(found?.refId).toBe('2') + }) + + it('findAllByRef returns every language row for an article', async () => { + await repository.upsertParent({ + refId: '3', + lang: 'en', + isTranslation: false, + sourceLang: null, + model: 'm', + voice: 'v', + speed: 1, + format: 'mp3', + blockOrder: [], + charCount: 0, + sourceModifiedAt: null, + }) + await repository.upsertParent({ + refId: '3', + lang: 'ja', + isTranslation: true, + sourceLang: 'en', + model: 'm', + voice: 'v', + speed: 1, + format: 'mp3', + blockOrder: [], + charCount: 0, + sourceModifiedAt: null, + }) + + const rows = await repository.findAllByRef('3') + expect(rows.map((r) => r.lang).sort()).toEqual(['en', 'ja']) + }) + + it('deleteBlocksByIds removes only the targeted block rows', async () => { + const parent4 = await repository.upsertParent({ + refId: '4', + lang: 'en', + isTranslation: false, + sourceLang: null, + model: 'm', + voice: 'v', + speed: 1, + format: 'mp3', + blockOrder: ['a', 'b'], + charCount: 2, + sourceModifiedAt: null, + }) + const blockA = await repository.upsertBlock({ + ttsId: parent4.id, + blockId: 'a', + chunkIndex: 0, + fingerprint: 'fpa', + text: 'a', + url: 'https://cdn/a.mp3', + storageBackend: 's3', + storageKey: 'k/a', + }) + await repository.upsertBlock({ + ttsId: parent4.id, + blockId: 'b', + chunkIndex: 0, + fingerprint: 'fpb', + text: 'b', + url: 'https://cdn/b.mp3', + storageBackend: 's3', + storageKey: 'k/b', + }) + + await repository.deleteBlocksByIds([blockA.id]) + + const remaining = await repository.findBlocks(parent4.id) + expect(remaining).toHaveLength(1) + expect(remaining[0].blockId).toBe('b') + }) + + it('deleteByRefId removes every language row for an article and returns their blocks', async () => { + const en = await repository.upsertParent({ + refId: '5', + lang: 'en', + isTranslation: false, + sourceLang: null, + model: 'm', + voice: 'v', + speed: 1, + format: 'mp3', + blockOrder: ['a'], + charCount: 1, + sourceModifiedAt: null, + }) + const ja = await repository.upsertParent({ + refId: '5', + lang: 'ja', + isTranslation: true, + sourceLang: 'en', + model: 'm', + voice: 'v', + speed: 1, + format: 'mp3', + blockOrder: ['a'], + charCount: 1, + sourceModifiedAt: null, + }) + await repository.upsertBlock({ + ttsId: en.id, + blockId: 'a', + chunkIndex: 0, + fingerprint: 'fp-en', + text: 'hi', + url: 'https://cdn/en.mp3', + storageBackend: 's3', + storageKey: 'k/en', + }) + await repository.upsertBlock({ + ttsId: ja.id, + blockId: 'a', + chunkIndex: 0, + fingerprint: 'fp-ja', + text: 'hi', + url: 'https://cdn/ja.mp3', + storageBackend: 's3', + storageKey: 'k/ja', + }) + + const removed = await repository.deleteByRefId('5') + expect(removed.map((b) => b.storageKey).sort()).toEqual(['k/en', 'k/ja']) + expect(await repository.findAllByRef('5')).toEqual([]) + expect(await repository.findBlocks(en.id)).toEqual([]) + expect(await repository.findBlocks(ja.id)).toEqual([]) + }) + + it('listPaginated paginates rows by page and size', async () => { + await repository.upsertParent({ + refId: '6', + lang: 'en', + isTranslation: false, + sourceLang: null, + model: 'm', + voice: 'v', + speed: 1, + format: 'mp3', + blockOrder: [], + charCount: 0, + sourceModifiedAt: null, + }) + await repository.upsertParent({ + refId: '7', + lang: 'en', + isTranslation: false, + sourceLang: null, + model: 'm', + voice: 'v', + speed: 1, + format: 'mp3', + blockOrder: [], + charCount: 0, + sourceModifiedAt: null, + }) + + const all = await repository.listPaginated({ page: 1, size: 100 }) + expect(all.data.length).toBeGreaterThanOrEqual(2) + expect(all.pagination.currentPage).toBe(1) + expect(all.pagination.size).toBe(100) + expect(all.pagination.total).toBe(all.data.length) + + const totalCount = all.data.length + const firstPage = await repository.listPaginated({ page: 1, size: 1 }) + expect(firstPage.data).toHaveLength(1) + expect(firstPage.pagination.size).toBe(1) + expect(firstPage.pagination.total).toBe(totalCount) + expect(firstPage.pagination.totalPage).toBe(totalCount) + expect(firstPage.pagination.hasNextPage).toBe(true) + expect(firstPage.pagination.hasPrevPage).toBe(false) + + const secondPage = await repository.listPaginated({ page: 2, size: 1 }) + expect(secondPage.data).toHaveLength(1) + expect(secondPage.pagination.currentPage).toBe(2) + expect(secondPage.pagination.hasPrevPage).toBe(true) + expect(secondPage.data[0].id).not.toBe(firstPage.data[0].id) + }) +}) diff --git a/apps/core/test/src/modules/ai/ai-tts/ai-tts.schema.spec.ts b/apps/core/test/src/modules/ai/ai-tts/ai-tts.schema.spec.ts new file mode 100644 index 00000000000..3f3ea487cd0 --- /dev/null +++ b/apps/core/test/src/modules/ai/ai-tts/ai-tts.schema.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' + +import { CreateTtsTaskSchema } from '~/modules/ai/ai-tts/ai-tts.schema' + +describe('CreateTtsTaskSchema', () => { + it('accepts a request without langs', () => { + expect(CreateTtsTaskSchema.safeParse({ refId: '1' }).success).toBe(true) + }) + + it('accepts resolvable language codes, including aliases', () => { + const result = CreateTtsTaskSchema.safeParse({ + refId: '1', + langs: ['zh-CN', 'en', 'jp'], + }) + expect(result.success).toBe(true) + }) + + it('rejects a langs array longer than 8', () => { + const result = CreateTtsTaskSchema.safeParse({ + refId: '1', + langs: ['en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'ru', 'pt'], + }) + expect(result.success).toBe(false) + }) + + it('rejects an entry parseLanguageCode cannot resolve', () => { + const result = CreateTtsTaskSchema.safeParse({ + refId: '1', + langs: ['zh', 'z'], + }) + expect(result.success).toBe(false) + }) +}) diff --git a/apps/core/test/src/modules/ai/ai-tts/ai-tts.service.spec.ts b/apps/core/test/src/modules/ai/ai-tts/ai-tts.service.spec.ts new file mode 100644 index 00000000000..bd35aff3434 --- /dev/null +++ b/apps/core/test/src/modules/ai/ai-tts/ai-tts.service.spec.ts @@ -0,0 +1,66 @@ +import { createPgRepositoryMock } from 'test/helper/pg-repository-mock' +import { describe, expect, it, vi } from 'vitest' + +import type { AiTtsRepository } from '~/modules/ai/ai-tts/ai-tts.repository' +import { AiTtsService } from '~/modules/ai/ai-tts/ai-tts.service' + +function createHarness() { + const configService = { get: vi.fn(async () => ({})) } + const fileService = { deleteObject: vi.fn(async () => {}) } + const taskProcessor = { registerHandler: vi.fn() } + const repository = createPgRepositoryMock() + const databaseService = { findGlobalById: vi.fn() } + const lexicalService = { extractRootBlockNodes: vi.fn() } + const translationRepository = { findByRefAndLang: vi.fn() } + const redisService = { getClient: vi.fn() } + + const service = new AiTtsService( + configService as any, + fileService as any, + taskProcessor as any, + repository as any, + databaseService as any, + lexicalService as any, + translationRepository as any, + redisService as any, + ) + + return { fileService, repository, service } +} + +describe('AiTtsService.deleteById', () => { + it('removes the narration row and its stored audio objects', async () => { + const { fileService, repository, service } = createHarness() + repository.deleteById.mockResolvedValue([ + { storageBackend: 's3', storageKey: 'k/a' }, + { storageBackend: 'local', storageKey: 'tts/1/zh/b.mp3' }, + ] as any) + + await service.deleteById('tts-1') + + expect(repository.deleteById).toHaveBeenCalledWith('tts-1') + expect(fileService.deleteObject).toHaveBeenCalledWith('s3', 'k/a') + expect(fileService.deleteObject).toHaveBeenCalledWith( + 'local', + 'tts/1/zh/b.mp3', + ) + }) + + it('is a no-op when the id does not match any narration', async () => { + const { fileService, repository, service } = createHarness() + repository.deleteById.mockResolvedValue([]) + + await expect(service.deleteById('missing')).resolves.toBeUndefined() + expect(fileService.deleteObject).not.toHaveBeenCalled() + }) + + it('survives a failing object deletion', async () => { + const { fileService, repository, service } = createHarness() + repository.deleteById.mockResolvedValue([ + { storageBackend: 's3', storageKey: 'k/a' }, + ] as any) + fileService.deleteObject.mockRejectedValue(new Error('network')) + + await expect(service.deleteById('tts-1')).resolves.toBeUndefined() + }) +}) diff --git a/apps/core/test/src/modules/ai/ai-tts/tts-block-plan.spec.ts b/apps/core/test/src/modules/ai/ai-tts/tts-block-plan.spec.ts new file mode 100644 index 00000000000..036582bcda8 --- /dev/null +++ b/apps/core/test/src/modules/ai/ai-tts/tts-block-plan.spec.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'vitest' + +import { + computeSpeechFingerprint, + extractSpeakableText, + planTts, + SPEAKABLE_BLOCK_TYPES, + splitIntoChunks, +} from '~/modules/ai/ai-tts/tts-block-plan' + +const textNode = (text: string) => ({ type: 'text', text }) + +describe('SPEAKABLE_BLOCK_TYPES', () => { + it('accepts prose blocks and rejects the rest', () => { + expect(SPEAKABLE_BLOCK_TYPES.has('paragraph')).toBe(true) + expect(SPEAKABLE_BLOCK_TYPES.has('heading')).toBe(true) + expect(SPEAKABLE_BLOCK_TYPES.has('quote')).toBe(true) + expect(SPEAKABLE_BLOCK_TYPES.has('rich-quote')).toBe(true) + expect(SPEAKABLE_BLOCK_TYPES.has('list')).toBe(true) + for (const type of [ + 'code', + 'mermaid', + 'excalidraw', + 'image', + 'gallery', + 'table', + 'poll', + 'embed', + 'horizontalrule', + ]) { + expect(SPEAKABLE_BLOCK_TYPES.has(type)).toBe(false) + } + }) +}) + +describe('extractSpeakableText', () => { + it('joins list items with a separator instead of concatenating', () => { + const list = { + type: 'list', + children: [ + { type: 'listitem', children: [textNode('ab')] }, + { type: 'listitem', children: [textNode('c')] }, + ], + } + expect(extractSpeakableText(list)).toBe('ab。c') + }) + + it('drops the url of a link but keeps its text', () => { + const paragraph = { + type: 'paragraph', + children: [ + { + type: 'link', + url: 'https://example.com', + children: [textNode('docs')], + }, + ], + } + expect(extractSpeakableText(paragraph)).toBe('docs') + }) + + it('collapses whitespace', () => { + const paragraph = { + type: 'paragraph', + children: [textNode('a \n b')], + } + expect(extractSpeakableText(paragraph)).toBe('a b') + }) +}) + +describe('computeSpeechFingerprint', () => { + it('separates list splits that share concatenated text', () => { + const left = { + type: 'list', + children: [ + { type: 'listitem', children: [textNode('ab')] }, + { type: 'listitem', children: [textNode('c')] }, + ], + } + const right = { + type: 'list', + children: [ + { type: 'listitem', children: [textNode('a')] }, + { type: 'listitem', children: [textNode('bc')] }, + ], + } + expect( + computeSpeechFingerprint('list', extractSpeakableText(left)), + ).not.toBe(computeSpeechFingerprint('list', extractSpeakableText(right))) + }) + + it('separates nested list splits that share concatenated text', () => { + const nestedList = (a: string, b: string) => ({ + type: 'list', + children: [ + { + type: 'listitem', + children: [ + { + type: 'list', + children: [ + { type: 'listitem', children: [textNode(a)] }, + { type: 'listitem', children: [textNode(b)] }, + ], + }, + ], + }, + ], + }) + const left = nestedList('ab', 'c') + const right = nestedList('a', 'bc') + expect( + computeSpeechFingerprint('list', extractSpeakableText(left)), + ).not.toBe(computeSpeechFingerprint('list', extractSpeakableText(right))) + }) +}) + +describe('splitIntoChunks', () => { + it('splits on sentence boundaries under the limit', () => { + expect(splitIntoChunks('一。二。三。', 4)).toEqual(['一。二。', '三。']) + }) + + it('hard-cuts a single sentence longer than the limit', () => { + expect(splitIntoChunks('a'.repeat(9), 4)).toEqual(['aaaa', 'aaaa', 'a']) + }) + + it('returns one chunk when the text fits', () => { + expect(splitIntoChunks('short', 100)).toEqual(['short']) + }) + + it('rejects a non-positive maxChars up front instead of looping unboundedly', () => { + expect(() => splitIntoChunks('a', 0)).toThrow(/maxChars must be a positive/) + expect(() => splitIntoChunks('a', -1)).toThrow( + /maxChars must be a positive/, + ) + }) +}) + +describe('planTts', () => { + const chunk = (blockId: string, chunkIndex: number, fingerprint: string) => ({ + blockId, + chunkIndex, + type: 'paragraph', + text: `${blockId}-${chunkIndex}`, + fingerprint, + }) + const objectKeyFor = (input: { + blockId: string + chunkIndex: number + fingerprint: string + }) => `alloy/${input.blockId}-${input.chunkIndex}-${input.fingerprint}.mp3` + const row = ( + id: string, + blockId: string, + chunkIndex: number, + fingerprint: string, + storageKey = objectKeyFor({ blockId, chunkIndex, fingerprint }), + ) => ({ + id, + blockId, + chunkIndex, + fingerprint, + storageBackend: 's3' as const, + storageKey, + }) + + it('reuses a matching fingerprint and regenerates a changed one', () => { + const plan = planTts({ + chunks: [chunk('a', 0, 'fp-a'), chunk('b', 0, 'fp-b2')], + existing: [row('r1', 'a', 0, 'fp-a'), row('r2', 'b', 0, 'fp-b1')], + force: false, + objectKeyFor, + }) + + expect(plan.toReuse).toEqual([{ rowId: 'r1', blockId: 'a', chunkIndex: 0 }]) + expect(plan.toGenerate).toEqual([chunk('b', 0, 'fp-b2')]) + expect(plan.toDelete).toEqual([]) + }) + + it('regenerates a row whose stored object was written under another voice', () => { + const plan = planTts({ + chunks: [chunk('a', 0, 'fp-a'), chunk('b', 0, 'fp-b')], + existing: [ + row('r1', 'a', 0, 'fp-a', 'nova/a-0-fp-a.mp3'), + row('r2', 'b', 0, 'fp-b'), + ], + force: false, + objectKeyFor, + }) + + expect(plan.toGenerate).toEqual([chunk('a', 0, 'fp-a')]) + expect(plan.toReuse).toEqual([{ rowId: 'r2', blockId: 'b', chunkIndex: 0 }]) + expect(plan.toDelete).toEqual([]) + }) + + it('keeps a moved block reused and reflects the move in blockOrder', () => { + const plan = planTts({ + chunks: [chunk('b', 0, 'fp-b'), chunk('a', 0, 'fp-a')], + existing: [row('r1', 'a', 0, 'fp-a'), row('r2', 'b', 0, 'fp-b')], + force: false, + objectKeyFor, + }) + + expect(plan.toGenerate).toEqual([]) + expect(plan.blockOrder).toEqual(['b', 'a']) + }) + + it('deletes rows for removed blocks and trailing chunks', () => { + const plan = planTts({ + chunks: [chunk('a', 0, 'fp-a')], + existing: [ + row('r1', 'a', 0, 'fp-a'), + row('r2', 'a', 1, 'fp-a1'), + row('r3', 'gone', 0, 'fp-x'), + ], + force: false, + objectKeyFor, + }) + + expect(plan.toDelete.map((d) => d.rowId).sort()).toEqual(['r2', 'r3']) + }) + + it('force regenerates everything and only deletes rows not being replaced', () => { + const plan = planTts({ + chunks: [chunk('a', 0, 'fp-a')], + existing: [row('r1', 'a', 0, 'fp-a'), row('r2', 'old', 0, 'fp-o')], + force: true, + objectKeyFor, + }) + + expect(plan.toGenerate).toHaveLength(1) + expect(plan.toDelete.map((d) => d.rowId)).toEqual(['r2']) + }) + + it('sums charCount over the planned chunks', () => { + const plan = planTts({ + chunks: [chunk('a', 0, 'fp-a'), chunk('b', 0, 'fp-b')], + existing: [], + force: false, + objectKeyFor, + }) + + expect(plan.charCount).toBe(6) + }) + + it('dedupes blockOrder so a multi-chunk block appears once', () => { + const plan = planTts({ + chunks: [chunk('a', 0, 'fp-a0'), chunk('a', 1, 'fp-a1')], + existing: [], + force: false, + objectKeyFor, + }) + + expect(plan.blockOrder).toEqual(['a']) + }) +}) diff --git a/apps/core/test/src/modules/ai/ai-tts/tts-lang-lock.spec.ts b/apps/core/test/src/modules/ai/ai-tts/tts-lang-lock.spec.ts new file mode 100644 index 00000000000..6444418e2d1 --- /dev/null +++ b/apps/core/test/src/modules/ai/ai-tts/tts-lang-lock.spec.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + ttsLangLockKey, + withTtsLangLock, +} from '~/modules/ai/ai-tts/tts-lang-lock' + +function createRedis() { + const store = new Map() + return { + store, + set: vi.fn(async (key: string, value: string) => { + if (store.has(key)) return null + store.set(key, value) + return 'OK' + }), + eval: vi.fn( + async (script: string, _keys: number, key: string, token: string) => { + if (store.get(key) !== token) return 0 + if (script.includes('del')) store.delete(key) + return 1 + }, + ), + } +} + +describe('withTtsLangLock', () => { + let redis: ReturnType + + beforeEach(() => { + redis = createRedis() + }) + + it('runs the body and releases the lock', async () => { + const result = await withTtsLangLock( + redis as never, + '1', + 'zh', + async () => 'done', + ) + + expect(result).toBe('done') + expect(redis.store.size).toBe(0) + }) + + it('releases the lock when the body throws', async () => { + await expect( + withTtsLangLock(redis as never, '1', 'zh', async () => { + throw new Error('boom') + }), + ).rejects.toThrow('boom') + + expect(redis.store.size).toBe(0) + }) + + it('returns null without running the body when the lock is held', async () => { + redis.store.set(ttsLangLockKey('1', 'zh'), 'someone-else') + const body = vi.fn() + + const result = await withTtsLangLock( + redis as never, + '1', + 'zh', + body as never, + ) + + expect(result).toBeNull() + expect(body).not.toHaveBeenCalled() + expect(redis.store.get(ttsLangLockKey('1', 'zh'))).toBe('someone-else') + }) + + it('never releases a lock it no longer owns', async () => { + await withTtsLangLock(redis as never, '1', 'zh', async () => { + redis.store.set(ttsLangLockKey('1', 'zh'), 'a-later-holder') + }) + + expect(redis.store.get(ttsLangLockKey('1', 'zh'))).toBe('a-later-holder') + }) + + it('keeps the body result when the release fails', async () => { + const onLockError = vi.fn() + redis.eval.mockRejectedValueOnce(new Error('redis blip')) + + const result = await withTtsLangLock( + redis as never, + '1', + 'zh', + async () => 'done', + onLockError, + ) + + expect(result).toBe('done') + expect(onLockError).toHaveBeenCalledWith(expect.any(Error), 'release') + }) + + it('keeps the body error when the release fails', async () => { + redis.eval.mockRejectedValueOnce(new Error('redis blip')) + + await expect( + withTtsLangLock( + redis as never, + '1', + 'zh', + async () => { + throw new Error('boom') + }, + vi.fn(), + ), + ).rejects.toThrow('boom') + }) + + it('clears the renewal interval so the process is not held open', async () => { + vi.useFakeTimers() + try { + await withTtsLangLock(redis as never, '1', 'zh', async () => 'done') + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('renews under the token and reports a renewal failure instead of rejecting', async () => { + vi.useFakeTimers() + const onLockError = vi.fn() + try { + const run = withTtsLangLock( + redis as never, + '1', + 'zh', + async () => { + redis.eval.mockRejectedValueOnce(new Error('redis blip')) + await vi.advanceTimersByTimeAsync(120_000) + return 'done' + }, + onLockError, + ) + + await expect(run).resolves.toBe('done') + expect(onLockError).toHaveBeenCalledWith(expect.any(Error), 'renew') + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/apps/core/test/src/modules/ai/ai-tts/tts-object-key.spec.ts b/apps/core/test/src/modules/ai/ai-tts/tts-object-key.spec.ts new file mode 100644 index 00000000000..5b4a10e755c --- /dev/null +++ b/apps/core/test/src/modules/ai/ai-tts/tts-object-key.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' + +import { + buildTtsObjectKey, + computeTtsObjectFingerprint, +} from '~/modules/ai/ai-tts/tts-object-key' + +const base = { + refId: '123', + lang: 'zh', + blockId: 'blk-a', + chunkIndex: 0, + fingerprint: 'abcdef1234567890', +} + +describe('buildTtsObjectKey', () => { + it('builds a content-addressed key', () => { + expect(buildTtsObjectKey(base)).toBe('tts/123/zh/blk-a-0-abcdef123456.mp3') + }) + + it('applies the storage prefix without doubling slashes', () => { + expect(buildTtsObjectKey({ ...base, prefix: 'media/' })).toBe( + 'media/tts/123/zh/blk-a-0-abcdef123456.mp3', + ) + }) + + it('changes the key when the fingerprint changes', () => { + expect( + buildTtsObjectKey({ ...base, fingerprint: 'ffffff0000001111' }), + ).not.toBe(buildTtsObjectKey(base)) + }) + + it('sanitizes path separators out of the block id', () => { + expect(buildTtsObjectKey({ ...base, blockId: '../escape' })).toBe( + 'tts/123/zh/--escape-0-abcdef123456.mp3', + ) + }) +}) + +describe('computeTtsObjectFingerprint', () => { + const voice = { model: 'tts-1', voice: 'alloy', speed: 1 } + + it('is stable for the same text and voice config', () => { + expect(computeTtsObjectFingerprint('fp', voice)).toBe( + computeTtsObjectFingerprint('fp', voice), + ) + }) + + it('changes when any part of the voice config changes', () => { + const baseline = computeTtsObjectFingerprint('fp', voice) + + expect( + computeTtsObjectFingerprint('fp', { ...voice, voice: 'nova' }), + ).not.toBe(baseline) + expect( + computeTtsObjectFingerprint('fp', { ...voice, model: 'tts-2' }), + ).not.toBe(baseline) + expect( + computeTtsObjectFingerprint('fp', { ...voice, speed: 1.25 }), + ).not.toBe(baseline) + }) + + it('changes when the speech fingerprint changes', () => { + expect(computeTtsObjectFingerprint('other', voice)).not.toBe( + computeTtsObjectFingerprint('fp', voice), + ) + }) +}) diff --git a/apps/core/test/src/modules/ai/ai-tts/tts-runtime.adapter.spec.ts b/apps/core/test/src/modules/ai/ai-tts/tts-runtime.adapter.spec.ts new file mode 100644 index 00000000000..f622dfa670e --- /dev/null +++ b/apps/core/test/src/modules/ai/ai-tts/tts-runtime.adapter.spec.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + resolveTtsBaseUrl, + TtsRuntimeAdapter, +} from '~/modules/ai/ai-tts/tts-runtime.adapter' +import { createAbortError } from '~/utils/abort.util' + +const audio = () => + new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { 'content-type': 'audio/mpeg' }, + }) + +afterEach(() => vi.unstubAllGlobals()) + +describe('resolveTtsBaseUrl', () => { + it('maps the presets and honours a custom endpoint', () => { + expect(resolveTtsBaseUrl('openrouter')).toBe('https://openrouter.ai/api/v1') + expect(resolveTtsBaseUrl('openai')).toBe('https://api.openai.com/v1') + expect(resolveTtsBaseUrl('custom', 'https://tts.local/v1/')).toBe( + 'https://tts.local/v1', + ) + }) +}) + +describe('TtsRuntimeAdapter', () => { + it('posts the OpenAI speech body and returns the audio buffer', async () => { + const fetchMock = vi.fn(async () => audio()) + vi.stubGlobal('fetch', fetchMock) + + const adapter = new TtsRuntimeAdapter({ + provider: 'openrouter', + apiKey: 'k', + model: 'openai/tts', + }) + const result = await adapter.generateSpeech({ + input: 'hello', + voice: 'alloy', + speed: 1, + }) + + expect(result.mimeType).toBe('audio/mpeg') + expect([...result.buffer]).toEqual([1, 2, 3]) + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://openrouter.ai/api/v1/audio/speech') + expect(JSON.parse(init.body as string)).toMatchObject({ + model: 'openai/tts', + input: 'hello', + voice: 'alloy', + speed: 1, + response_format: 'mp3', + }) + }) + + it('retries a 500 and succeeds on the next attempt', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('boom', { status: 500 })) + .mockResolvedValueOnce(audio()) + vi.stubGlobal('fetch', fetchMock) + + const adapter = new TtsRuntimeAdapter({ + provider: 'openrouter', + apiKey: 'k', + model: 'm', + retryDelayMs: 0, + }) + await adapter.generateSpeech({ input: 'x', voice: 'v', speed: 1 }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('does not retry a 400', async () => { + const fetchMock = vi.fn( + async () => new Response('bad voice', { status: 400 }), + ) + vi.stubGlobal('fetch', fetchMock) + + const adapter = new TtsRuntimeAdapter({ + provider: 'openrouter', + apiKey: 'k', + model: 'm', + retryDelayMs: 0, + }) + + await expect( + adapter.generateSpeech({ input: 'x', voice: 'v', speed: 1 }), + ).rejects.toThrow() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('fails fast on an aborted signal without retrying', async () => { + const controller = new AbortController() + controller.abort() + const fetchMock = vi.fn(async () => { + throw createAbortError() + }) + vi.stubGlobal('fetch', fetchMock) + + const adapter = new TtsRuntimeAdapter({ + provider: 'openrouter', + apiKey: 'k', + model: 'm', + retryDelayMs: 0, + }) + + await expect( + adapter.generateSpeech({ + input: 'x', + voice: 'v', + speed: 1, + signal: controller.signal, + }), + ).rejects.toThrow() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('rejects a non-audio 200 response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ error: 'quota' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ) + + const adapter = new TtsRuntimeAdapter({ + provider: 'openrouter', + apiKey: 'k', + model: 'm', + retryDelayMs: 0, + }) + + await expect( + adapter.generateSpeech({ input: 'x', voice: 'v', speed: 1 }), + ).rejects.toThrow(/quota/) + }) +}) diff --git a/apps/core/test/src/modules/ai/ai-tts/tts-source-content.spec.ts b/apps/core/test/src/modules/ai/ai-tts/tts-source-content.spec.ts new file mode 100644 index 00000000000..4ffed6b2685 --- /dev/null +++ b/apps/core/test/src/modules/ai/ai-tts/tts-source-content.spec.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest' + +import { AppErrorCode } from '~/common/errors' +import { toArticleContent } from '~/modules/ai/ai-translation/article-content.util' +import { resolveTtsSourceContent } from '~/modules/ai/ai-tts/tts-source-content' +import { computeContentHash } from '~/utils/content.util' + +const LEXICAL = '{"root":{"children":[]}}' + +const document = { + title: 'Narratable post', + text: 'plain text', + contentFormat: 'lexical', + content: LEXICAL, + meta: { lang: 'zh-CN' }, + modifiedAt: new Date('2026-01-01'), +} + +// The writer hashes the source article with `translated.sourceLang` and stores +// that same string on the row — see ai-translation.service.ts. +const translationRow = (overrides: Record = {}) => { + const sourceLang = (overrides.sourceLang as string) ?? 'zh' + return { + contentFormat: 'lexical', + content: '{"root":{"children":[1]}}', + sourceLang, + hash: computeContentHash(toArticleContent(document as never), sourceLang), + sourceModifiedAt: new Date('2025-06-01'), + createdAt: new Date('2025-05-01'), + ...overrides, + } +} + +function resolve( + row: unknown, + overrides: Record = {}, +): Promise<{ content: string; sourceModifiedAt: Date | null }> { + return resolveTtsSourceContent({ + document: document as never, + findTranslation: vi.fn(async () => row as never), + isTranslation: true, + lang: 'en', + refId: '1', + sourceLang: 'zh', + ...overrides, + }) +} + +describe('resolveTtsSourceContent', () => { + describe('source language', () => { + it('returns the article content stamped with the article mtime', async () => { + await expect( + resolve(null, { isTranslation: false, lang: 'zh' }), + ).resolves.toEqual({ + content: LEXICAL, + sourceModifiedAt: new Date('2026-01-01'), + }) + }) + + it('rejects a non-lexical article', async () => { + await expect( + resolveTtsSourceContent({ + document: { ...document, contentFormat: 'markdown' } as never, + findTranslation: vi.fn(), + isTranslation: false, + lang: 'zh', + refId: '1', + sourceLang: 'zh', + }), + ).rejects.toMatchObject({ code: AppErrorCode.TTS_SOURCE_NOT_LEXICAL }) + }) + }) + + describe('translated language', () => { + it('accepts a fresh translation and stamps the translation vintage', async () => { + await expect(resolve(translationRow())).resolves.toEqual({ + content: '{"root":{"children":[1]}}', + sourceModifiedAt: new Date('2025-06-01'), + }) + }) + + it('accepts a fresh translation whose sourceLang carries a region subtag', async () => { + await expect( + resolve(translationRow({ sourceLang: 'zh-CN' })), + ).resolves.toMatchObject({ content: '{"root":{"children":[1]}}' }) + }) + + it('falls back to createdAt when the row has no source vintage', async () => { + await expect( + resolve(translationRow({ sourceModifiedAt: null })), + ).resolves.toMatchObject({ sourceModifiedAt: new Date('2025-05-01') }) + }) + + it('rejects a translation left behind by an edited article', async () => { + await expect( + resolve(translationRow({ hash: 'hash-of-an-older-article' })), + ).rejects.toMatchObject({ code: AppErrorCode.TTS_SOURCE_NOT_LEXICAL }) + }) + + it('rejects a missing translation row', async () => { + await expect(resolve(null)).rejects.toMatchObject({ + code: AppErrorCode.TTS_SOURCE_NOT_LEXICAL, + }) + }) + + it('rejects a non-lexical translation', async () => { + await expect( + resolve(translationRow({ contentFormat: 'markdown' })), + ).rejects.toMatchObject({ code: AppErrorCode.TTS_SOURCE_NOT_LEXICAL }) + }) + + it('rejects a translation made from a different source language', async () => { + await expect( + resolve(translationRow({ sourceLang: 'en' })), + ).rejects.toMatchObject({ code: AppErrorCode.TTS_SOURCE_NOT_LEXICAL }) + }) + }) +}) diff --git a/apps/core/test/src/modules/configs/configs.dsl.util.spec.ts b/apps/core/test/src/modules/configs/configs.dsl.util.spec.ts index 2da6e8fde0a..c94aae3f15e 100644 --- a/apps/core/test/src/modules/configs/configs.dsl.util.spec.ts +++ b/apps/core/test/src/modules/configs/configs.dsl.util.spec.ts @@ -78,6 +78,41 @@ describe('generateFormDSL', () => { expect(field?.required).toBeFalsy() } }) + + test('only shows the ttsOptions endpoint field when provider is custom', () => { + const dsl = generateFormDSL() + + const ttsSection = dsl.groups + .find((group) => group.key === 'ai') + ?.sections.find((section) => section.key === 'ttsOptions') + + const endpointField = ttsSection?.fields.find( + (field) => field.key === 'endpoint', + ) + expect(endpointField?.ui.showWhen).toEqual({ provider: 'custom' }) + + const providerField = ttsSection?.fields.find( + (field) => field.key === 'provider', + ) + expect(providerField?.ui.options).toEqual([ + { label: 'OpenRouter', value: 'openrouter' }, + { label: 'OpenAI', value: 'openai' }, + { label: 'Custom', value: 'custom' }, + ]) + }) + + test('ttsOptions apiKey/endpoint/model/voice stay non-required despite the null-coercing transform', () => { + const dsl = generateFormDSL() + + const ttsSection = dsl.groups + .find((group) => group.key === 'ai') + ?.sections.find((section) => section.key === 'ttsOptions') + + for (const key of ['apiKey', 'endpoint', 'model', 'voice']) { + const field = ttsSection?.fields.find((f) => f.key === key) + expect(field?.required).toBeFalsy() + } + }) }) describe('attachImageModelOptionsToFormDSL', () => { diff --git a/apps/core/test/src/modules/configs/configs.service.spec.ts b/apps/core/test/src/modules/configs/configs.service.spec.ts index 6c7cf54a9db..04a55db16c8 100644 --- a/apps/core/test/src/modules/configs/configs.service.spec.ts +++ b/apps/core/test/src/modules/configs/configs.service.spec.ts @@ -363,6 +363,83 @@ describe('ConfigsService', () => { }) }) + describe('ttsOptions provider validation', () => { + function createService( + currentConfig: ReturnType, + ) { + const redisClient = { + get: vi.fn().mockResolvedValue(JSON.stringify(currentConfig)), + set: vi.fn().mockResolvedValue('OK'), + } + const redisService = { + getClient: vi.fn(() => redisClient), + waitForReady: vi.fn().mockResolvedValue(undefined), + } + const optionsRepository = { + findAll: vi.fn().mockResolvedValue([]), + upsert: vi.fn(async (name: string, value: unknown) => ({ + id: '1' as any, + name, + value, + })), + } + const eventManager = { emit: vi.fn() } + + const service = new ConfigsService( + optionsRepository as any, + redisService as any, + {} as any, + eventManager as any, + ) + + return { service } + } + + it('rejects provider: "custom" with a blank endpoint', async () => { + const { service } = createService(generateDefaultConfig()) + + await expect( + service.patchAndValid('ttsOptions', { + provider: 'custom', + }), + ).rejects.toMatchObject({ code: AppErrorCode.CONFIG_VALIDATION_FAILED }) + }) + + it('accepts provider: "custom" when an endpoint is already configured', async () => { + const currentConfig = generateDefaultConfig() + currentConfig.ttsOptions.endpoint = 'https://existing.example.com/v1' + const { service } = createService(currentConfig) + + const result = await service.patchAndValid('ttsOptions', { + provider: 'custom', + }) + + expect(result.provider).toBe('custom') + }) + + it('accepts provider: "custom" together with a newly-supplied endpoint in the same patch', async () => { + const { service } = createService(generateDefaultConfig()) + + const result = await service.patchAndValid('ttsOptions', { + provider: 'custom', + endpoint: 'https://new-custom.example.com/v1', + }) + + expect(result.provider).toBe('custom') + expect(result.endpoint).toBe('https://new-custom.example.com/v1') + }) + + it('leaves the default "openrouter" provider unaffected by the custom-endpoint check', async () => { + const { service } = createService(generateDefaultConfig()) + + const result = await service.patchAndValid('ttsOptions', { + provider: 'openrouter', + }) + + expect(result.provider).toBe('openrouter') + }) + }) + describe('S3 and image-generation option save round-trips', () => { function createService( currentConfig: ReturnType, diff --git a/apps/core/test/src/modules/file/file-reference-reconciliation.pg.e2e.spec.ts b/apps/core/test/src/modules/file/file-reference-reconciliation.pg.e2e.spec.ts index 2a05d5cfd28..9de73f29e9f 100644 --- a/apps/core/test/src/modules/file/file-reference-reconciliation.pg.e2e.spec.ts +++ b/apps/core/test/src/modules/file/file-reference-reconciliation.pg.e2e.spec.ts @@ -83,7 +83,7 @@ describe('File reference reconciliation (real PG and filesystem)', () => { beforeEach(async () => { await pool.query( - 'TRUNCATE TABLE file_usages, file_references, posts, snippets, topics, categories, readers RESTART IDENTITY CASCADE', + 'TRUNCATE TABLE file_usages, file_references, posts, snippets, topics, categories, readers, ai_tts, ai_tts_blocks RESTART IDENTITY CASCADE', ) await rm(root, { force: true, recursive: true }) await mkdir(root, { recursive: true }) @@ -96,7 +96,7 @@ describe('File reference reconciliation (real PG and filesystem)', () => { await rm(root, { force: true, recursive: true }) }) - const putFile = async (type: 'file' | 'image', name: string) => { + const putFile = async (type: 'audio' | 'file' | 'image', name: string) => { const target = path.join(root, type, name) await mkdir(path.dirname(target), { recursive: true }) await writeFile(target, `fixture:${name}`) @@ -358,6 +358,108 @@ describe('File reference reconciliation (real PG and filesystem)', () => { }) }) + it('classifies a TTS audio object as referenced while its ai_tts_blocks row exists', async () => { + const audioUrl = `${serverUrl}/objects/audio/tts/9001/en/blk-0-abc123456789.mp3` + await putFile('audio', 'tts/9001/en/blk-0-abc123456789.mp3') + + await db.insert(schema.aiTts).values({ + id: '9001', + refId: '9001', + lang: 'en', + model: 'tts-1', + voice: 'alloy', + speed: 1, + format: 'mp3', + blockOrder: ['blk-0'], + charCount: 5, + }) + await db.insert(schema.aiTtsBlocks).values({ + id: '9002', + ttsId: '9001', + blockId: 'blk-0', + fingerprint: 'fp-9001', + chunkIndex: 0, + text: 'hello', + url: audioUrl, + storageBackend: 'local', + storageKey: 'tts/9001/en/blk-0-abc123456789.mp3', + }) + + await expect( + usageRepository.findReferencedUrls([audioUrl]), + ).resolves.toEqual(new Set([audioUrl])) + await expect(usageRepository.findUsageMatches([audioUrl])).resolves.toEqual( + [ + { + fileUrl: audioUrl, + sourceField: 'url', + sourceId: '9002', + sourceType: 'ai_tts', + }, + ], + ) + + await reconciliation.reconcile({ apply: true }) + + const [reference] = await db + .select({ status: schema.fileReferences.status }) + .from(schema.fileReferences) + .where(eq(schema.fileReferences.fileUrl, audioUrl)) + expect(reference?.status).toBe(FileReferenceStatus.Active) + }) + + it('classifies a TTS audio object as isolated once its ai_tts_blocks row is gone', async () => { + const audioUrl = `${serverUrl}/objects/audio/tts/9001/en/blk-0-abc123456789.mp3` + await putFile('audio', 'tts/9001/en/blk-0-abc123456789.mp3') + + await db.insert(schema.aiTts).values({ + id: '9001', + refId: '9001', + lang: 'en', + model: 'tts-1', + voice: 'alloy', + speed: 1, + format: 'mp3', + blockOrder: ['blk-0'], + charCount: 5, + }) + await db.insert(schema.aiTtsBlocks).values({ + id: '9002', + ttsId: '9001', + blockId: 'blk-0', + fingerprint: 'fp-9001', + chunkIndex: 0, + text: 'hello', + url: audioUrl, + storageBackend: 'local', + storageKey: 'tts/9001/en/blk-0-abc123456789.mp3', + }) + await reconciliation.reconcile({ apply: true }) + + const [referencedBefore] = await db + .select({ status: schema.fileReferences.status }) + .from(schema.fileReferences) + .where(eq(schema.fileReferences.fileUrl, audioUrl)) + expect(referencedBefore?.status).toBe(FileReferenceStatus.Active) + + await db.delete(schema.aiTtsBlocks).where(eq(schema.aiTtsBlocks.id, '9002')) + + await expect( + usageRepository.findReferencedUrls([audioUrl]), + ).resolves.toEqual(new Set()) + await expect(usageRepository.findUsageMatches([audioUrl])).resolves.toEqual( + [], + ) + + await reconciliation.reconcile({ apply: true }) + + const [reference] = await db + .select({ status: schema.fileReferences.status }) + .from(schema.fileReferences) + .where(eq(schema.fileReferences.fileUrl, audioUrl)) + expect(reference?.status).toBe(FileReferenceStatus.Pending) + }) + it('rolls back usage replacement and status changes when an insert fails', async () => { await db.insert(schema.fileReferences).values([ { diff --git a/apps/core/test/src/modules/file/file-upload-audio.spec.ts b/apps/core/test/src/modules/file/file-upload-audio.spec.ts new file mode 100644 index 00000000000..10d7e510c71 --- /dev/null +++ b/apps/core/test/src/modules/file/file-upload-audio.spec.ts @@ -0,0 +1,220 @@ +import { access } from 'node:fs/promises' +import { Readable } from 'node:stream' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { FileService } from '~/modules/file/file.service' + +const { uploadBufferMock, deleteObjectMock, setCustomDomainMock } = vi.hoisted( + () => ({ + uploadBufferMock: vi.fn(), + deleteObjectMock: vi.fn(), + setCustomDomainMock: vi.fn(), + }), +) + +vi.mock('~/utils/s3.util', () => ({ + S3Uploader: vi.fn(function (this: Record) { + this.uploadBuffer = uploadBufferMock + this.deleteObject = deleteObjectMock + this.setCustomDomain = setCustomDomainMock + }), +})) + +function createService(overrides: { s3Enabled: boolean; prefix?: string }) { + const configService = { + get: vi.fn(async (key: string) => { + if (key === 'fileUploadOptions') { + return { + enableCustomNaming: true, + filenameTemplate: '{name}{ext}', + pathTemplate: '{type}', + } + } + if (key === 'imageStorageOptions') { + return { + enable: overrides.s3Enabled, + endpoint: 'https://s3.example.com', + secretId: 'id', + secretKey: 'key', + bucket: 'bucket', + region: 'auto', + prefix: overrides.prefix ?? '', + customDomain: '', + } + } + if (key === 'url') return { serverUrl: 'https://example.com' } + return {} + }), + } + const fileReferenceService = { createPendingReference: vi.fn() } + const service = new FileService( + configService as any, + fileReferenceService as any, + ) + vi.spyOn(service, 'writeFile').mockResolvedValue(undefined as any) + return { service, fileReferenceService } +} + +beforeEach(() => { + uploadBufferMock.mockReset() + deleteObjectMock.mockReset() + setCustomDomainMock.mockReset() +}) + +describe('FileService.uploadBuffer audio on the local backend', () => { + it('uses the explicit objectKey instead of the filename template', async () => { + const { service } = createService({ s3Enabled: false }) + const result = await service.uploadBuffer(Buffer.from('x'), { + type: 'audio', + contentType: 'audio/mpeg', + objectKey: 'tts/1/zh/blk-0-abcdef123456.mp3', + }) + + expect(result.storageBackend).toBe('local') + expect(result.storageKey).toBe('tts/1/zh/blk-0-abcdef123456.mp3') + expect(result.url).toBe( + 'https://example.com/objects/audio/tts/1/zh/blk-0-abcdef123456.mp3', + ) + }) + + it('creates the pending reference the orphan system tracks the audio by', async () => { + const { service, fileReferenceService } = createService({ + s3Enabled: false, + }) + await service.uploadBuffer(Buffer.from('x'), { + type: 'audio', + contentType: 'audio/mpeg', + objectKey: 'tts/1/zh/blk-0-abcdef123456.mp3', + }) + + expect(fileReferenceService.createPendingReference).toHaveBeenCalledWith( + 'https://example.com/objects/audio/tts/1/zh/blk-0-abcdef123456.mp3', + 'tts/1/zh/blk-0-abcdef123456.mp3', + ) + }) + + it('rejects a repeat write of the same object key so the caller can treat it as already stored', async () => { + const { service } = createService({ s3Enabled: false }) + vi.mocked(service.writeFile).mockRestore() + const objectKey = `tts/file-exists-${Date.now()}/a.mp3` + const upload = () => + service.uploadBuffer(Buffer.from('x'), { + type: 'audio', + contentType: 'audio/mpeg', + objectKey, + }) + + await upload() + + await expect(upload()).rejects.toMatchObject({ code: 'FILE_EXISTS' }) + + await service.deleteObject('local', objectKey) + }) +}) + +describe('FileService.uploadBuffer audio on the s3 backend', () => { + it('uploads the explicit objectKey and references it by basename', async () => { + const { service, fileReferenceService } = createService({ + s3Enabled: true, + prefix: 'blog', + }) + uploadBufferMock.mockResolvedValue( + 'https://cdn.example.com/tts/1/zh/blk-0-abcdef123456.mp3', + ) + + const result = await service.uploadBuffer(Buffer.from('x'), { + type: 'audio', + contentType: 'audio/mpeg', + objectKey: 'blog/tts/1/zh/blk-0-abcdef123456.mp3', + }) + + expect(uploadBufferMock).toHaveBeenCalledWith( + expect.any(Buffer), + 'blog/tts/1/zh/blk-0-abcdef123456.mp3', + 'audio/mpeg', + ) + expect(result).toEqual({ + url: 'https://cdn.example.com/tts/1/zh/blk-0-abcdef123456.mp3', + name: 'blk-0-abcdef123456.mp3', + storageBackend: 's3', + storageKey: 'blog/tts/1/zh/blk-0-abcdef123456.mp3', + }) + expect(fileReferenceService.createPendingReference).toHaveBeenCalledWith( + 'https://cdn.example.com/tts/1/zh/blk-0-abcdef123456.mp3', + 'blk-0-abcdef123456.mp3', + 'blog/tts/1/zh/blk-0-abcdef123456.mp3', + ) + }) + + it('rejects when the bucket credentials are incomplete', async () => { + const configService = { + get: vi.fn(async (key: string) => { + if (key === 'imageStorageOptions') { + return { enable: true, endpoint: '', secretId: '', secretKey: '' } + } + return {} + }), + } + const service = new FileService( + configService as any, + { createPendingReference: vi.fn() } as any, + ) + + await expect( + service.uploadBuffer(Buffer.from('x'), { + type: 'audio', + contentType: 'audio/mpeg', + objectKey: 'tts/1/zh/a.mp3', + }), + ).rejects.toMatchObject({ code: 'FILE_STORAGE_NOT_CONFIGURED' }) + expect(uploadBufferMock).not.toHaveBeenCalled() + }) +}) + +describe('FileService.deleteObject', () => { + it('removes a local audio object', async () => { + const { service } = createService({ s3Enabled: false }) + vi.mocked(service.writeFile).mockRestore() + const objectKey = `tts/delete-${Date.now()}/a.mp3` + await service.writeFile('audio', objectKey, Readable.from(Buffer.from('x'))) + + await service.deleteObject('local', objectKey) + + await expect( + access(service['resolveFilePath']('audio', objectKey)), + ).rejects.toThrow() + }) + + it('treats a missing local object as already deleted', async () => { + const { service } = createService({ s3Enabled: false }) + + await expect( + service.deleteObject('local', 'tts/absent/a.mp3'), + ).resolves.toBeUndefined() + }) + + it('forwards an s3 key to the bucket', async () => { + const { service } = createService({ s3Enabled: true }) + deleteObjectMock.mockResolvedValue(undefined) + + await service.deleteObject('s3', 'blog/tts/1/zh/a.mp3') + + expect(deleteObjectMock).toHaveBeenCalledWith('blog/tts/1/zh/a.mp3') + }) + + it('rejects an s3 delete when the bucket is not configured', async () => { + const configService = { + get: vi.fn(async () => ({ endpoint: '', secretId: '', secretKey: '' })), + } + const service = new FileService( + configService as any, + { createPendingReference: vi.fn() } as any, + ) + + await expect( + service.deleteObject('s3', 'blog/tts/1/zh/a.mp3'), + ).rejects.toMatchObject({ code: 'FILE_STORAGE_NOT_CONFIGURED' }) + expect(deleteObjectMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/core/test/src/modules/file/file.service.spec.ts b/apps/core/test/src/modules/file/file.service.spec.ts index c80bb80dfa9..3f191fffddd 100644 --- a/apps/core/test/src/modules/file/file.service.spec.ts +++ b/apps/core/test/src/modules/file/file.service.spec.ts @@ -222,6 +222,8 @@ describe('FileService.uploadBuffer', () => { expect(result).toEqual({ url: 'https://cdn.example.com/f.bin', name: 'origin.png', + storageBackend: 's3', + storageKey: 'blog/image/origin.png', }) }) @@ -270,6 +272,8 @@ describe('FileService.uploadBuffer', () => { expect(result).toEqual({ url: 'http://example.com/objects/image/nested/origin.png', name: 'origin.png', + storageBackend: 'local', + storageKey: 'nested/origin.png', }) }) @@ -306,6 +310,8 @@ describe('FileService.uploadBuffer', () => { expect(result).toEqual({ url: 'http://example.com/objects/file/abc.bin', name: 'abc.bin', + storageBackend: 'local', + storageKey: 'abc.bin', }) }) }) diff --git a/apps/core/test/src/modules/note/note.controller.e2e-spec.ts b/apps/core/test/src/modules/note/note.controller.e2e-spec.ts index 1adefbac7b4..a81ee165bc5 100644 --- a/apps/core/test/src/modules/note/note.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/note/note.controller.e2e-spec.ts @@ -49,6 +49,7 @@ const createController = ( countingService?: Record aiInsightsService?: Record aiSummaryService?: Record + aiTtsQueryService?: Record } = {}, ) => { const noteService = { @@ -120,12 +121,18 @@ const createController = ( ...overrides.aiSummaryService, } + const aiTtsQueryService = { + getMetaForArticle: vi.fn().mockResolvedValue({ available: false }), + ...overrides.aiTtsQueryService, + } + const controller = new NoteController( noteService as any, countingService as any, translationService as any, aiSummaryService as any, aiInsightsService as any, + aiTtsQueryService as any, {} as any, enrichmentService as any, translationEntryService as any, @@ -137,6 +144,7 @@ const createController = ( translationService, translationEntryService, enrichmentService, + aiTtsQueryService, } } @@ -396,6 +404,81 @@ describe('NoteController', () => { }) }) + describe('meta.tts matches what the public narration endpoint will serve', () => { + const ttsMeta = { + available: true, + lang: 'zh', + blockCount: 2, + stale: false, + updatedAt: new Date('2026-01-02'), + } + + it('suppresses meta.tts for an anonymous reader of a future-dated secret note', async () => { + const { controller } = createController({ + noteService: { + findByNid: vi.fn().mockResolvedValue(makeNote({ id: 'note-secret' })), + checkNoteIsSecret: vi.fn().mockReturnValue(true), + }, + aiTtsQueryService: { + getMetaForArticle: vi.fn().mockResolvedValue(ttsMeta), + }, + }) + + const response = await controller.getNoteByNid( + { nid: 1 } as any, + false, + {} as any, + 'fake-ip', + ) + + expect(response.meta.tts).toEqual({ available: false }) + }) + + it('keeps meta.tts for the owner of a future-dated secret note', async () => { + const { controller } = createController({ + noteService: { + findByNid: vi.fn().mockResolvedValue(makeNote({ id: 'note-secret' })), + checkNoteIsSecret: vi.fn().mockReturnValue(true), + }, + aiTtsQueryService: { + getMetaForArticle: vi.fn().mockResolvedValue(ttsMeta), + }, + }) + + const response = await controller.getNoteByNid( + { nid: 1 } as any, + true, + {} as any, + 'fake-ip', + ) + + expect(response.meta.tts).toEqual(ttsMeta) + }) + + it('keeps meta.tts for a reader who cleared the note password gate', async () => { + const { controller } = createController({ + noteService: { + findByNid: vi + .fn() + .mockResolvedValue(makeNote({ id: 'note-locked', password: 'x' })), + checkPasswordToAccess: vi.fn().mockResolvedValue(true), + }, + aiTtsQueryService: { + getMetaForArticle: vi.fn().mockResolvedValue(ttsMeta), + }, + }) + + const response = await controller.getNoteByNid( + { nid: 1 } as any, + false, + { password: 'x' } as any, + 'fake-ip', + ) + + expect(response.meta.tts).toEqual(ttsMeta) + }) + }) + describe('GET /?lang=en — list translation in place', () => { it('emits meta.translation only for translated items', async () => { const doc1 = makeNote({ id: 'list-1', title: 'ZH 1', text: 'text 1' }) diff --git a/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts b/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts index e16c4176085..d7e503c6055 100644 --- a/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts +++ b/apps/core/test/src/modules/post/post-paywall.e2e-spec.ts @@ -11,6 +11,7 @@ import { PG_DB_TOKEN } from '~/constants/system.constant' import { AiInsightsService } from '~/modules/ai/ai-insights/ai-insights.service' import { AiSummaryService } from '~/modules/ai/ai-summary/ai-summary.service' import { TranslationEntryService } from '~/modules/ai/ai-translation/translation-entry.service' +import { AiTtsQueryService } from '~/modules/ai/ai-tts/ai-tts-query.service' import { AuthService } from '~/modules/auth/auth.service' import { ConfigsService } from '~/modules/configs/configs.service' import { EnrichmentService } from '~/modules/enrichment/enrichment.service' @@ -205,6 +206,18 @@ const postModule: ModuleMetadata = { provide: AiSummaryService, useValue: { getSummaryForPublicMeta: vi.fn(async () => null) }, }, + { + provide: AiTtsQueryService, + useValue: { + getMetaForArticle: vi.fn(async () => ({ + available: true, + lang: 'zh', + blockCount: 3, + stale: false, + updatedAt: new Date('2024-01-02'), + })), + }, + }, { provide: EnrichmentService, useValue: { @@ -453,6 +466,42 @@ describe('Post paywall enforcement (e2e)', () => { ) }) + it('reports meta.tts.available:false when locked even though narration exists', async () => { + currentPost = { ...premiumPostFixture } + + const res = await proxy.app.inject({ + method: 'GET', + url: `/posts/${premiumPostFixture.category.slug}/${premiumPostFixture.slug}`, + headers: headerFor(nonMemberReaderId), + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.meta.paywall.locked).toBe(true) + expect(body.meta.tts).toEqual({ available: false }) + }) + + it('surfaces the real meta.tts for an entitled reader', async () => { + currentPost = { ...premiumPostFixture } + + const res = await proxy.app.inject({ + method: 'GET', + url: `/posts/${premiumPostFixture.category.slug}/${premiumPostFixture.slug}`, + headers: headerFor(activeMemberReaderId), + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.meta.paywall.locked).toBe(false) + expect(body.meta.tts).toEqual({ + available: true, + lang: 'zh', + block_count: 3, + stale: false, + updated_at: '2024-01-02T00:00:00.000Z', + }) + }) + it('getById applies the same gate for a non-member reader', async () => { currentPost = { ...premiumPostFixture } diff --git a/apps/core/test/src/modules/post/post-skill.e2e-spec.ts b/apps/core/test/src/modules/post/post-skill.e2e-spec.ts index edf2305938a..0682595fe2b 100644 --- a/apps/core/test/src/modules/post/post-skill.e2e-spec.ts +++ b/apps/core/test/src/modules/post/post-skill.e2e-spec.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { AiInsightsService } from '~/modules/ai/ai-insights/ai-insights.service' import { AiSummaryService } from '~/modules/ai/ai-summary/ai-summary.service' import { TranslationEntryService } from '~/modules/ai/ai-translation/translation-entry.service' +import { AiTtsQueryService } from '~/modules/ai/ai-tts/ai-tts-query.service' import { EnrichmentService } from '~/modules/enrichment/enrichment.service' import { EntitlementService } from '~/modules/membership/entitlement.service' import { PostController } from '~/modules/post/post.controller' @@ -87,6 +88,12 @@ const proxy = createE2EApp({ provide: AiSummaryService, useValue: { getSummaryForPublicMeta: vi.fn(async () => null) }, }, + { + provide: AiTtsQueryService, + useValue: { + getMetaForArticle: vi.fn(async () => ({ available: false })), + }, + }, { provide: EnrichmentService, useValue: { diff --git a/apps/core/test/src/modules/post/post.controller.spec.ts b/apps/core/test/src/modules/post/post.controller.spec.ts index 87e1e032ba3..ef308636b75 100644 --- a/apps/core/test/src/modules/post/post.controller.spec.ts +++ b/apps/core/test/src/modules/post/post.controller.spec.ts @@ -106,6 +106,10 @@ const createController = (opts: CreateControllerOptions = {}) => { getSummaryForPublicMeta: vi.fn(async () => null), } + const aiTtsQueryService = { + getMetaForArticle: vi.fn(async () => ({ available: false })), + } + const snippetService = { findSkillBundlesByIds: vi.fn(async () => []), } @@ -120,6 +124,7 @@ const createController = (opts: CreateControllerOptions = {}) => { translationService as any, aiInsightsService as any, aiSummaryService as any, + aiTtsQueryService as any, enrichmentService as any, translationEntryService as any, snippetService as any, @@ -134,6 +139,7 @@ const createController = (opts: CreateControllerOptions = {}) => { translationEntryService, snippetService, entitlementService, + aiTtsQueryService, } } diff --git a/apps/core/test/src/processors/helper/lexical-root-block-nodes.spec.ts b/apps/core/test/src/processors/helper/lexical-root-block-nodes.spec.ts new file mode 100644 index 00000000000..26fee4a00c8 --- /dev/null +++ b/apps/core/test/src/processors/helper/lexical-root-block-nodes.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' + +import { + BLOCK_ID_STATE_KEY, + NODE_STATE_KEY, +} from '~/constants/lexical.constant' +import { LexicalService } from '~/processors/helper/helper.lexical.service' +import { md5 } from '~/utils/tool.util' + +function withBlockId(node: Record, blockId: string) { + return { ...node, [NODE_STATE_KEY]: { [BLOCK_ID_STATE_KEY]: blockId } } +} + +function textNode(text: string) { + return { type: 'text', text } +} + +const CONTENT = JSON.stringify({ + root: { + children: [ + withBlockId( + { type: 'paragraph', children: [textNode('hello')] }, + 'blk-a', + ), + withBlockId({ type: 'code', code: 'const a = 1' }, 'blk-b'), + ], + }, +}) + +describe('LexicalService.extractRootBlockNodes', () => { + it('returns the underlying node alongside id and type', () => { + const service = new LexicalService() + const nodes = service.extractRootBlockNodes(CONTENT) + + expect(nodes).toHaveLength(2) + expect(nodes[0].id).toBe('blk-a') + expect(nodes[0].type).toBe('paragraph') + expect(nodes[0].node.children[0].text).toBe('hello') + expect(nodes[1].index).toBe(1) + }) + + it('produces byte-identical extractRootBlocks output for a mixed-type document', () => { + const service = new LexicalService() + const state = JSON.stringify({ + root: { + children: [ + withBlockId( + { type: 'paragraph', children: [textNode('Hello world')] }, + 'para0001', + ), + withBlockId( + { type: 'heading', tag: 'h1', children: [textNode('Title')] }, + 'head0001', + ), + withBlockId( + { + type: 'list', + listType: 'bullet', + children: [ + { type: 'listitem', children: [textNode('first')] }, + { type: 'listitem', children: [textNode('second')] }, + ], + }, + 'list0001', + ), + withBlockId({ type: 'code', code: 'const a = 1' }, 'code0001'), + withBlockId( + { type: 'mermaid', diagram: 'graph TD\n A-->B' }, + 'merm0001', + ), + withBlockId( + { + type: 'banner', + bannerType: 'warning', + content: { + root: { + children: [ + { type: 'paragraph', children: [textNode('Danger ahead')] }, + ], + }, + }, + }, + 'bann0001', + ), + ], + }, + }) + + const blocks = service.extractRootBlocks(state) + + expect( + blocks.map(({ id, type, text, index }) => ({ id, type, text, index })), + ).toEqual([ + { id: 'para0001', type: 'paragraph', text: 'Hello world', index: 0 }, + { id: 'head0001', type: 'heading', text: 'Title', index: 1 }, + { id: 'list0001', type: 'list', text: 'firstsecond', index: 2 }, + { id: 'code0001', type: 'code', text: 'const a = 1', index: 3 }, + { + id: 'merm0001', + type: 'mermaid', + text: 'graph TD\n A-->B', + index: 4, + }, + { id: 'bann0001', type: 'banner', text: 'Danger ahead', index: 5 }, + ]) + + expect(blocks.map((b) => b.fingerprint)).toEqual([ + md5('paragraph:Hello world'), + md5('heading:Title'), + md5('list:firstsecond'), + md5('code:const a = 1'), + md5('mermaid:graph TD A-->B'), + md5('banner:Danger ahead'), + ]) + }) +}) diff --git a/apps/core/test/src/utils/s3.util.spec.ts b/apps/core/test/src/utils/s3.util.spec.ts index e58ac735c8b..6d6673ae1f2 100644 --- a/apps/core/test/src/utils/s3.util.spec.ts +++ b/apps/core/test/src/utils/s3.util.spec.ts @@ -49,7 +49,10 @@ describe('S3Uploader.uploadStream', () => { async function* generate() { let offset = 0 while (offset < total) { - const size = Math.min(chunkSizes[seed++ % chunkSizes.length], total - offset) + const size = Math.min( + chunkSizes[seed++ % chunkSizes.length], + total - offset, + ) yield input.subarray(offset, offset + size) offset += size } @@ -92,7 +95,11 @@ describe('S3Uploader.uploadStream', () => { }, ) - await uploader.uploadStream(Readable.from([]), 'files/empty.bin', 'application/octet-stream') + await uploader.uploadStream( + Readable.from([]), + 'files/empty.bin', + 'application/octet-stream', + ) expect(partBodies.length).toBe(1) expect(partBodies[0].length).toBe(0) diff --git a/docs/superpowers/specs/2026-08-05-ai-tts-design.md b/docs/superpowers/specs/2026-08-05-ai-tts-design.md index 1cb1aeaf85f..a9cd468ac1e 100644 --- a/docs/superpowers/specs/2026-08-05-ai-tts-design.md +++ b/docs/superpowers/specs/2026-08-05-ai-tts-design.md @@ -664,3 +664,127 @@ Suggested order for the plan, each phase independently verifiable: instance) surface as generation failures rather than being validated up front. - Cache invalidation infrastructure (the 15s/60s anonymous cache window is accepted) + +--- + +## Post-implementation deviations + +The sections above are the design as approved. Implementation, review and human +rulings moved four things. The original prose is kept as written so the +reasoning behind each reversal stays legible; where the two disagree, **this +section is authoritative**. + +### 1. Audio rides the general file-reference system — `skipReference` is gone + +*Supersedes §5 (`So uploadBuffer takes a skipReference option ... ai_tts_blocks +is the reference`) and the "Audio file lifecycle" summary row.* + +The design's fear was correct at the time it was written: `createPendingReference` +plus a 60-minute orphan reap would have deleted narration an hour after it was +generated. It was fixed upstream rather than around. `file_references` grew a +usage-source registry, and `ai_tts_blocks.url` is registered in +`findReferencedUrls` (`file-reference-usage.repository.ts`), so a narration +object is *referenced* for as long as its row exists and becomes isolated the +instant the row is deleted — the same lifecycle every other owner-uploaded file +has. + +Human ruling (Task 11): TTS audio is "just another kind of isolated file"; ride +the upstream orphan system instead of a bespoke sweep. Consequently: + +- `uploadBuffer` takes **no** `skipReference` option — it was removed outright + once this doc stopped instructing readers to use it. Every audio upload + creates a pending reference, exactly like an image. +- `CronTaskType.CleanupTtsOrphans`, `tts-orphan-reconciliation.ts`, + `listObjectsUnderPrefix` and `S3Uploader.listObjects` do not exist. §5's + fourth deletion path ("a reconciliation pass for objects orphaned by a crash + between upload and commit") is served by the general orphan cleanup cron. +- The other three deletion paths (run supersession, `DELETE /ai/tts/:id`, + article-delete handlers) are implemented as designed. + +Known limitation, accepted: the upstream inventory walks local storage only, so +an S3 object orphaned by a crash between upload and commit is not enumerated. +This is not a TTS regression — it is how the inventory already worked. + +### 2. The object key folds in the voice triple + +*Supersedes §5 "Object key scheme" and the "Object key" summary row.* + +The spec addressed the object by the speech fingerprint alone. That is a defect: +a `force` run with a changed voice would compute the *same* key as the audio it +replaces. The local backend rejects the write as `FILE_EXISTS` and the caller's +recovery keeps the OLD audio while the row records the NEW voice; S3 overwrites +in place behind a one-year cache header. + +The stored key is therefore addressed by text **and** voice +(`tts-object-key.ts`): + +``` +{s3Prefix}/tts/{refId}/{lang}/{blockId}-{chunkIndex}-{objectFingerprint12}.mp3 +objectFingerprint = md5(`${speechFingerprint}|${model}|${voice}|${speed}`) +``` + +The reuse decision keys on the *whole object key*, not the speech fingerprint +alone (`PlanTtsInput.objectKeyFor`). A row whose `storageKey` is not what this +run would write is regenerated. That is what makes the parent's "voice locked at +generation time" invariant crash-safe: a `force` run that dies after rewriting +some rows leaves the parent still pointing at the old voice, and the next +incremental run notices the mismatched keys and re-narrates those rows back into +the parent's voice instead of leaving the article permanently mixed-voice. + +§5's claim that "the `tts/{refId}/` prefix ... is what the orphan-reconciliation +pass and article-delete cleanup use as their ground truth" is false — nothing +lists objects by prefix any more (see deviation 1). The prefix survives purely +as human-legible bucket layout. + +### 3. Narration is entitlement-aware, not premium-blind + +*Supersedes §6's "an unentitled reader gets `null`" paragraph, which the first +implementation read as a blanket block on `isPremium`.* + +`GET /ai/tts/article/:id` takes reader identity — +`@HasAdminAccess()`, `@CurrentReaderId()` and an optional `?password=` for +notes — and judges access with the same machinery `PostController.applyPaywall` +uses: + +- **Premium posts.** `EntitlementService.isPremiumLocked({ isPremium, isOwner, + readerId })` is the single implementation of the rule, shared with + `applyPaywall` through `isEntitledToPremium`. A paying member, the owner, and + every reader on a site where membership is not purchasable all hear the + narration; only an unentitled reader on a selling site gets `null`. A blanket + `isPremium` block was rejected: it turns the feature off for exactly the tier + it was gated for. +- **Notes.** `isArticleVisibleToViewer` replaces the anonymous-only + `isGlobalArticleVisible` on this path. A reader who supplies the correct note + password hears the narration; the owner additionally sees drafts and + future-dated secrets. `NoteService.checkPasswordToAccess` performs the + comparison, so there is no second password check. +- `meta.tts` on post and note detail applies the *same* rule, so the meta never + advertises narration the endpoint refuses to serve, and never suppresses + narration the endpoint would serve. Note detail additionally reports + `available: false` to an anonymous reader of a future-dated secret note, whose + text is blanked in the same response. + +While implementing this, `isGlobalArticleVisible`'s note-password check was +found to be reading a field that does not exist on a loaded row: +`NoteRepository` projects the column to `hasPassword` and drops the secret, so +`document.password` was always `undefined`. The predicate now accepts either +field. This tightens summary, insights and translation as well — all four AI +features shared the gap. + +**Caching.** §6's cache paragraph holds, and the mechanism was re-verified. +`HttpCacheInterceptor` returns before touching Redis whenever the request +carries any identity (`hasAdminAccess || hasReaderIdentity || isAuthenticated`) +and the route sets no `force` cache option — and NestJS runs guards before +interceptors, so `RolesGuard` has already stamped the request by then. An +entitled reader's narration is therefore never read from, nor written into, the +shared cache. The anonymous cache key is the full URL including the query +string, so a note's password-bearing request and its password-less request +occupy different entries. The TTS routes add no cache decorator of their own. + +### 4. `TTS_SOURCE_NOT_LEXICAL` is thrown per language + +*Refines §5's "throw only when every requested language fails".* + +The error is thrown per language and caught by the per-language loop; the +**task** still only reaches `Failed` when every attempted language failed. The +operator gets a per-language reason instead of one opaque failure. diff --git a/packages/api-client/__tests__/controllers/ai.test.ts b/packages/api-client/__tests__/controllers/ai.test.ts index d3274bc84dc..f4b9dca9696 100644 --- a/packages/api-client/__tests__/controllers/ai.test.ts +++ b/packages/api-client/__tests__/controllers/ai.test.ts @@ -31,4 +31,24 @@ describe('test ai client', () => { }), ).resolves.not.toThrowError() }) + + test('getTts requests the article narration', async () => { + mockResponse( + '/ai/tts/article/post-1?lang=zh', + { + lang: 'zh', + model: 'tts-1', + voice: 'nova', + blockOrder: ['block-1'], + segments: [], + }, + 'get', + ) + + const response = await client.ai.getTts({ articleId: 'post-1', lang: 'zh' }) + expect(response).not.toHaveProperty('error') + expect(response.lang).toBe('zh') + expect(response.model).toBe('tts-1') + expect(response.voice).toBe('nova') + }) }) diff --git a/packages/api-client/controllers/ai.ts b/packages/api-client/controllers/ai.ts index 98f3565791b..2eba03f0991 100644 --- a/packages/api-client/controllers/ai.ts +++ b/packages/api-client/controllers/ai.ts @@ -9,6 +9,7 @@ import type { AIInsightsModel, AISummaryModel, AITranslationModel, + AITtsModel, } from '../models/ai' declare module '@mx-space/api-client' { @@ -248,4 +249,10 @@ export class AIController implements IController { }, }) } + + async getTts({ articleId, lang }: { articleId: string; lang?: string }) { + return this.proxy.tts.article(articleId).get({ + params: { lang }, + }) + } } diff --git a/packages/api-client/models/ai.ts b/packages/api-client/models/ai.ts index 3fbe2617b30..9159a42147c 100644 --- a/packages/api-client/models/ai.ts +++ b/packages/api-client/models/ai.ts @@ -99,3 +99,18 @@ export type AIInsightsStreamEvent = | { type: 'token'; data: string } | { type: 'done'; data: undefined } | { type: 'error'; data: string } + +export interface AITtsSegmentModel { + blockId: string + chunkIndex: number + text: string + url: string +} + +export interface AITtsModel { + lang: string + model: string + voice: string + blockOrder: string[] + segments: AITtsSegmentModel[] +} diff --git a/packages/db-schema/src/schema/ai.ts b/packages/db-schema/src/schema/ai.ts index 80b3d6d337e..49da90af6ae 100644 --- a/packages/db-schema/src/schema/ai.ts +++ b/packages/db-schema/src/schema/ai.ts @@ -3,8 +3,10 @@ import type { AnyPgColumn } from 'drizzle-orm/pg-core' import { boolean, index, + integer, jsonb, pgTable, + real, text, uniqueIndex, } from 'drizzle-orm/pg-core' @@ -124,3 +126,59 @@ export const aiAgentConversations = pgTable( }, (table) => [index('ai_agent_conversation_session_idx').on(table.sessionId)], ) + +export const aiTts = pgTable( + 'ai_tts', + { + id: pkText(), + createdAt: createdAt(), + updatedAt: updatedAt(), + refId: refText('ref_id').notNull(), + lang: text('lang').notNull(), + isTranslation: boolean('is_translation').notNull().default(false), + sourceLang: text('source_lang'), + model: text('model').notNull(), + voice: text('voice').notNull(), + speed: real('speed').notNull().default(1), + format: text('format').notNull().default('mp3'), + blockOrder: jsonb('block_order') + .$type() + .notNull() + .default(sql`'[]'::jsonb`), + charCount: integer('char_count').notNull().default(0), + totalDurationMs: integer('total_duration_ms'), + sourceModifiedAt: tsCol('source_modified_at'), + }, + (table) => [ + uniqueIndex('ai_tts_ref_lang_uniq').on(table.refId, table.lang), + index('ai_tts_ref_id_idx').on(table.refId), + ], +) + +export const aiTtsBlocks = pgTable( + 'ai_tts_blocks', + { + id: pkText(), + createdAt: createdAt(), + ttsId: refText('tts_id') + .notNull() + .references((): AnyPgColumn => aiTts.id, { onDelete: 'cascade' }), + blockId: text('block_id').notNull(), + fingerprint: text('fingerprint').notNull(), + chunkIndex: integer('chunk_index').notNull().default(0), + text: text('text').notNull(), + url: text('url').notNull(), + storageBackend: text('storage_backend').notNull(), + storageKey: text('storage_key').notNull(), + byteSize: integer('byte_size'), + durationMs: integer('duration_ms'), + }, + (table) => [ + uniqueIndex('ai_tts_blocks_key_uniq').on( + table.ttsId, + table.blockId, + table.chunkIndex, + ), + index('ai_tts_blocks_tts_id_idx').on(table.ttsId), + ], +)