From c2188b476f159622e714a3aa91cf03fd62e36995 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 17 Feb 2026 19:30:12 -0700 Subject: [PATCH] feat: Refactor server-side document, audiobook, and authentication logic, introduce new client-side APIs and caching, and update related tests. --- src/app/(app)/epub/[id]/page.tsx | 2 +- src/app/(app)/html/[id]/page.tsx | 2 +- src/app/(app)/layout.tsx | 2 +- src/app/(app)/pdf/[id]/page.tsx | 2 +- src/app/(app)/signin/page.tsx | 2 +- src/app/(app)/signup/page.tsx | 2 +- src/app/(public)/page.tsx | 4 +- src/app/api/account/delete/route.ts | 4 +- src/app/api/audiobook/chapter/route.ts | 14 +- src/app/api/audiobook/route.ts | 14 +- src/app/api/audiobook/status/route.ts | 14 +- src/app/api/auth/[...all]/route.ts | 2 +- .../api/documents/blob/get/fallback/route.ts | 10 +- .../api/documents/blob/get/presign/route.ts | 8 +- .../documents/blob/preview/ensure/route.ts | 4 +- .../documents/blob/preview/fallback/route.ts | 49 +++++- .../documents/blob/preview/presign/route.ts | 4 +- src/app/api/documents/blob/preview/utils.ts | 10 +- src/app/api/documents/blob/route.ts | 129 --------------- .../documents/blob/upload/fallback/route.ts | 8 +- .../documents/blob/upload/presign/route.ts | 8 +- .../api/documents/docx-to-pdf/upload/route.ts | 12 +- .../api/documents/library/content/route.ts | 4 +- src/app/api/documents/library/route.ts | 4 +- src/app/api/documents/route.ts | 12 +- src/app/api/rate-limit/status/route.ts | 10 +- src/app/api/tts/route.ts | 12 +- src/app/api/tts/voices/route.ts | 4 +- src/app/api/user/claim/route.ts | 6 +- src/app/api/user/export/route.ts | 12 +- src/app/api/user/state/preferences/route.ts | 2 +- src/app/api/user/state/progress/route.ts | 4 +- src/app/api/whisper/route.ts | 6 +- src/components/AudiobookExportModal.tsx | 2 +- src/components/ConsentAwareAnalytics.tsx | 2 +- src/components/CookieConsentBanner.tsx | 2 +- src/components/PrivacyModal.tsx | 2 +- src/components/SettingsModal.tsx | 10 +- src/components/auth/AuthLoader.tsx | 2 +- src/components/auth/UserMenu.tsx | 2 +- src/components/doclist/DocumentList.tsx | 2 +- src/components/doclist/DocumentPreview.tsx | 4 +- .../documents/DexieMigrationModal.tsx | 6 +- src/components/documents/DocumentUploader.tsx | 2 +- src/components/player/VoicesControlBase.tsx | 2 +- src/contexts/ConfigContext.tsx | 4 +- src/contexts/DocumentContext.tsx | 4 +- src/contexts/EPUBContext.tsx | 12 +- src/contexts/HTMLContext.tsx | 4 +- src/contexts/PDFContext.tsx | 10 +- src/contexts/TTSContext.tsx | 10 +- src/db/schema_postgres.ts | 48 +++--- src/db/schema_sqlite.ts | 48 +++--- src/hooks/audio/useVoiceManagement.ts | 2 +- src/hooks/epub/useEPUBDocuments.ts | 4 +- src/hooks/epub/useEPUBResize.ts | 2 +- src/hooks/html/useHTMLDocuments.ts | 4 +- src/hooks/pdf/usePDFDocuments.ts | 4 +- src/hooks/pdf/usePDFResize.ts | 2 +- src/hooks/useAuthSession.ts | 2 +- src/lib/{ => client}/analytics.ts | 0 .../{client.ts => client/api/audiobooks.ts} | 0 .../api/documents.ts} | 6 +- .../api/user-state.ts} | 0 src/lib/{ => client}/auth-client.ts | 0 .../cache/documents.ts} | 24 +-- .../cache/previews.ts} | 4 +- src/lib/{ => client}/dexie.ts | 6 +- src/lib/{ => client}/epub.ts | 0 .../pdf-highlight-worker.ts} | 0 src/lib/{ => client}/pdf.ts | 2 +- src/lib/{ => client}/sha256.ts | 0 src/lib/documentPreview.ts | 150 ------------------ .../blobstore.ts} | 2 +- .../{audiobook.ts => audiobooks/chapters.ts} | 2 +- src/lib/server/{ => audiobooks}/ffmpeg-bin.ts | 0 .../prune.ts} | 0 .../user-scope.ts} | 0 src/lib/server/{ => auth}/auth.ts | 8 +- .../server/{auth-config.ts => auth/config.ts} | 0 .../blobstore.ts} | 2 +- .../previews-blobstore.ts} | 4 +- .../previews-render.ts} | 0 .../previews.ts} | 6 +- .../{ => server/documents}/text-snippets.ts | 0 .../utils.ts} | 0 src/lib/server/{ => rate-limit}/device-id.ts | 0 .../server/{ => rate-limit}/rate-limiter.ts | 2 +- src/lib/server/{ => rate-limit}/request-ip.ts | 0 .../docstore-legacy.ts} | 0 .../{library.ts => storage/library-mount.ts} | 0 src/lib/server/{ => storage}/s3.ts | 0 .../server/{ => testing}/test-namespace.ts | 2 +- src/lib/server/{ => user}/claim-data.ts | 8 +- .../data-cleanup.ts} | 8 +- .../{user-export.ts => user/data-export.ts} | 2 +- .../resolve-state-scope.ts} | 6 +- src/lib/{ => shared}/kokoro.ts | 0 src/lib/{ => shared}/nlp.ts | 0 tests/global-teardown.ts | 6 +- tests/unit/audiobook-scope.spec.ts | 2 +- tests/unit/audiobook.spec.ts | 2 +- tests/unit/audiobooks-blobstore.spec.ts | 2 +- tests/unit/auth-config.spec.ts | 2 +- tests/unit/docstore.spec.ts | 2 +- tests/unit/document-cache.spec.ts | 2 +- tests/unit/document-previews-render.spec.ts | 2 +- tests/unit/nlp.spec.ts | 2 +- tests/unit/sha256.spec.ts | 2 +- tests/unit/transfer-user-documents.spec.ts | 2 +- 110 files changed, 302 insertions(+), 548 deletions(-) delete mode 100644 src/app/api/documents/blob/route.ts rename src/lib/{ => client}/analytics.ts (100%) rename src/lib/{client.ts => client/api/audiobooks.ts} (100%) rename src/lib/{client-documents.ts => client/api/documents.ts} (98%) rename src/lib/{client-user-state.ts => client/api/user-state.ts} (100%) rename src/lib/{ => client}/auth-client.ts (100%) rename src/lib/{document-cache.ts => client/cache/documents.ts} (85%) rename src/lib/{document-preview-cache.ts => client/cache/previews.ts} (98%) rename src/lib/{ => client}/dexie.ts (99%) rename src/lib/{ => client}/epub.ts (100%) rename src/lib/{pdfHighlightWorker.ts => client/pdf-highlight-worker.ts} (100%) rename src/lib/{ => client}/pdf.ts (99%) rename src/lib/{ => client}/sha256.ts (100%) delete mode 100644 src/lib/documentPreview.ts rename src/lib/server/{audiobooks-blobstore.ts => audiobooks/blobstore.ts} (99%) rename src/lib/server/{audiobook.ts => audiobooks/chapters.ts} (98%) rename src/lib/server/{ => audiobooks}/ffmpeg-bin.ts (100%) rename src/lib/server/{audiobook-prune.ts => audiobooks/prune.ts} (100%) rename src/lib/server/{audiobook-scope.ts => audiobooks/user-scope.ts} (100%) rename src/lib/server/{ => auth}/auth.ts (98%) rename src/lib/server/{auth-config.ts => auth/config.ts} (100%) rename src/lib/server/{documents-blobstore.ts => documents/blobstore.ts} (99%) rename src/lib/server/{document-previews-blobstore.ts => documents/previews-blobstore.ts} (97%) rename src/lib/server/{document-previews-render.ts => documents/previews-render.ts} (100%) rename src/lib/server/{document-previews.ts => documents/previews.ts} (98%) rename src/lib/{ => server/documents}/text-snippets.ts (100%) rename src/lib/server/{documents-utils.ts => documents/utils.ts} (100%) rename src/lib/server/{ => rate-limit}/device-id.ts (100%) rename src/lib/server/{ => rate-limit}/rate-limiter.ts (99%) rename src/lib/server/{ => rate-limit}/request-ip.ts (100%) rename src/lib/server/{docstore.ts => storage/docstore-legacy.ts} (100%) rename src/lib/server/{library.ts => storage/library-mount.ts} (100%) rename src/lib/server/{ => storage}/s3.ts (100%) rename src/lib/server/{ => testing}/test-namespace.ts (93%) rename src/lib/server/{ => user}/claim-data.ts (97%) rename src/lib/server/{user-data-cleanup.ts => user/data-cleanup.ts} (93%) rename src/lib/server/{user-export.ts => user/data-export.ts} (99%) rename src/lib/server/{user-state-scope.ts => user/resolve-state-scope.ts} (81%) rename src/lib/{ => shared}/kokoro.ts (100%) rename src/lib/{ => shared}/nlp.ts (100%) diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx index 09c281e..bae604c 100644 --- a/src/app/(app)/epub/[id]/page.tsx +++ b/src/app/(app)/epub/[id]/page.tsx @@ -15,7 +15,7 @@ import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu'; import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; -import { resolveDocumentId } from '@/lib/dexie'; +import { resolveDocumentId } from '@/lib/client/dexie'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; diff --git a/src/app/(app)/html/[id]/page.tsx b/src/app/(app)/html/[id]/page.tsx index 50c14e8..c1ab787 100644 --- a/src/app/(app)/html/[id]/page.tsx +++ b/src/app/(app)/html/[id]/page.tsx @@ -11,7 +11,7 @@ import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; import { Header } from '@/components/Header'; import { useTTS } from "@/contexts/TTSContext"; import TTSPlayer from '@/components/player/TTSPlayer'; -import { resolveDocumentId } from '@/lib/dexie'; +import { resolveDocumentId } from '@/lib/client/dexie'; import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index fe10418..fb14c2c 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -4,7 +4,7 @@ import { Toaster } from 'react-hot-toast'; import { Providers } from '@/app/providers'; import ClaimDataPopup from '@/components/auth/ClaimDataModal'; -import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '@/lib/server/auth-config'; +import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config'; export const dynamic = 'force-dynamic'; diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index 69661cf..7b43bb0 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -15,7 +15,7 @@ import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; import TTSPlayer from '@/components/player/TTSPlayer'; import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; -import { resolveDocumentId } from '@/lib/dexie'; +import { resolveDocumentId } from '@/lib/client/dexie'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; diff --git a/src/app/(app)/signin/page.tsx b/src/app/(app)/signin/page.tsx index 21532e2..288b46d 100644 --- a/src/app/(app)/signin/page.tsx +++ b/src/app/(app)/signin/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, Suspense } from 'react'; import { Button, Input } from '@headlessui/react'; import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; -import { getAuthClient } from '@/lib/auth-client'; +import { getAuthClient } from '@/lib/client/auth-client'; import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { showPrivacyModal } from '@/components/PrivacyModal'; import { GithubIcon } from '@/components/icons/Icons'; diff --git a/src/app/(app)/signup/page.tsx b/src/app/(app)/signup/page.tsx index a6f3f6c..fafed41 100644 --- a/src/app/(app)/signup/page.tsx +++ b/src/app/(app)/signup/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'; import { Button, Input } from '@headlessui/react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; -import { getAuthClient } from '@/lib/auth-client'; +import { getAuthClient } from '@/lib/client/auth-client'; import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { showPrivacyModal } from '@/components/PrivacyModal'; import { LoadingSpinner } from '@/components/Spinner'; diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index e84a231..991f292 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -3,8 +3,8 @@ import Link from 'next/link'; import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; -import { auth } from '@/lib/server/auth'; -import { isAuthEnabled } from '@/lib/server/auth-config'; +import { auth } from '@/lib/server/auth/auth'; +import { isAuthEnabled } from '@/lib/server/auth/config'; export const metadata: Metadata = { title: 'Read and Listen to Documents', diff --git a/src/app/api/account/delete/route.ts b/src/app/api/account/delete/route.ts index cf5d0a8..76358f6 100644 --- a/src/app/api/account/delete/route.ts +++ b/src/app/api/account/delete/route.ts @@ -1,7 +1,7 @@ import { headers } from 'next/headers'; import { NextResponse } from 'next/server'; -import { auth } from '@/lib/server/auth'; -import { isAuthEnabled } from '@/lib/server/auth-config'; +import { auth } from '@/lib/server/auth/auth'; +import { isAuthEnabled } from '@/lib/server/auth/config'; export async function DELETE() { if (!isAuthEnabled() || !auth) { diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 30d3609..0a75004 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -7,24 +7,24 @@ import { randomUUID } from 'crypto'; import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { audiobooks, audiobookChapters } from '@/db/schema'; -import { requireAuthContext } from '@/lib/server/auth'; +import { requireAuthContext } from '@/lib/server/auth/auth'; import { deleteAudiobookObject, getAudiobookObjectBuffer, isMissingBlobError, listAudiobookObjects, putAudiobookObject, -} from '@/lib/server/audiobooks-blobstore'; +} from '@/lib/server/audiobooks/blobstore'; import { decodeChapterFileName, encodeChapterFileName, encodeChapterTitleTag, ffprobeAudio, -} from '@/lib/server/audiobook'; -import { isS3Configured } from '@/lib/server/s3'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; -import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobook-scope'; -import { getFFmpegPath } from '@/lib/server/ffmpeg-bin'; +} from '@/lib/server/audiobooks/chapters'; +import { isS3Configured } from '@/lib/server/storage/s3'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope'; +import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin'; import type { AudiobookGenerationSettings } from '@/types/client'; import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts'; diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index 4f2fc96..e6abc47 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -6,7 +6,7 @@ import { join } from 'path'; import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { audiobooks, audiobookChapters } from '@/db/schema'; -import { requireAuthContext } from '@/lib/server/auth'; +import { requireAuthContext } from '@/lib/server/auth/auth'; import { audiobookPrefix, deleteAudiobookObject, @@ -14,16 +14,16 @@ import { getAudiobookObjectBuffer, listAudiobookObjects, putAudiobookObject, -} from '@/lib/server/audiobooks-blobstore'; +} from '@/lib/server/audiobooks/blobstore'; import { decodeChapterFileName, escapeFFMetadata, ffprobeAudio, -} from '@/lib/server/audiobook'; -import { isS3Configured } from '@/lib/server/s3'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; -import { getFFmpegPath } from '@/lib/server/ffmpeg-bin'; -import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobook-scope'; +} from '@/lib/server/audiobooks/chapters'; +import { isS3Configured } from '@/lib/server/storage/s3'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin'; +import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope'; import type { TTSAudiobookFormat } from '@/types/tts'; export const dynamic = 'force-dynamic'; diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index 3155fd4..78d0f53 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -2,13 +2,13 @@ import { NextRequest, NextResponse } from 'next/server'; import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { audiobooks, audiobookChapters } from '@/db/schema'; -import { requireAuthContext } from '@/lib/server/auth'; -import { getAudiobookObjectBuffer, isMissingBlobError, listAudiobookObjects } from '@/lib/server/audiobooks-blobstore'; -import { decodeChapterFileName } from '@/lib/server/audiobook'; -import { pruneAudiobookChaptersNotOnDisk } from '@/lib/server/audiobook-prune'; -import { isS3Configured } from '@/lib/server/s3'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; -import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobook-scope'; +import { requireAuthContext } from '@/lib/server/auth/auth'; +import { getAudiobookObjectBuffer, isMissingBlobError, listAudiobookObjects } from '@/lib/server/audiobooks/blobstore'; +import { decodeChapterFileName } from '@/lib/server/audiobooks/chapters'; +import { pruneAudiobookChaptersNotOnDisk } from '@/lib/server/audiobooks/prune'; +import { isS3Configured } from '@/lib/server/storage/s3'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope'; import type { AudiobookGenerationSettings } from '@/types/client'; import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts index b49da69..35ec1e4 100644 --- a/src/app/api/auth/[...all]/route.ts +++ b/src/app/api/auth/[...all]/route.ts @@ -1,4 +1,4 @@ -import { auth } from "@/lib/server/auth"; // path to your auth file +import { auth } from "@/lib/server/auth/auth"; // path to your auth file import { toNextJsHandler } from "better-auth/next-js"; const handlers = auth diff --git a/src/app/api/documents/blob/get/fallback/route.ts b/src/app/api/documents/blob/get/fallback/route.ts index 043bfcd..dc8e120 100644 --- a/src/app/api/documents/blob/get/fallback/route.ts +++ b/src/app/api/documents/blob/get/fallback/route.ts @@ -2,11 +2,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; -import { requireAuthContext } from '@/lib/server/auth'; -import { contentTypeForName } from '@/lib/server/library'; -import { getDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents-blobstore'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; -import { isS3Configured } from '@/lib/server/s3'; +import { requireAuthContext } from '@/lib/server/auth/auth'; +import { contentTypeForName } from '@/lib/server/storage/library-mount'; +import { getDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { isS3Configured } from '@/lib/server/storage/s3'; export const dynamic = 'force-dynamic'; diff --git a/src/app/api/documents/blob/get/presign/route.ts b/src/app/api/documents/blob/get/presign/route.ts index 4785b4c..dfdc38d 100644 --- a/src/app/api/documents/blob/get/presign/route.ts +++ b/src/app/api/documents/blob/get/presign/route.ts @@ -2,10 +2,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; -import { requireAuthContext } from '@/lib/server/auth'; -import { isValidDocumentId, presignGet } from '@/lib/server/documents-blobstore'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; -import { isS3Configured } from '@/lib/server/s3'; +import { requireAuthContext } from '@/lib/server/auth/auth'; +import { isValidDocumentId, presignGet } from '@/lib/server/documents/blobstore'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { isS3Configured } from '@/lib/server/storage/s3'; export const dynamic = 'force-dynamic'; diff --git a/src/app/api/documents/blob/preview/ensure/route.ts b/src/app/api/documents/blob/preview/ensure/route.ts index edd06bc..186ee98 100644 --- a/src/app/api/documents/blob/preview/ensure/route.ts +++ b/src/app/api/documents/blob/preview/ensure/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { presignDocumentPreviewGet } from '@/lib/server/document-previews-blobstore'; -import { ensureDocumentPreview } from '@/lib/server/document-previews'; +import { presignDocumentPreviewGet } from '@/lib/server/documents/previews-blobstore'; +import { ensureDocumentPreview } from '@/lib/server/documents/previews'; import { validatePreviewRequest } from '../utils'; export const dynamic = 'force-dynamic'; diff --git a/src/app/api/documents/blob/preview/fallback/route.ts b/src/app/api/documents/blob/preview/fallback/route.ts index 36cadff..2434faa 100644 --- a/src/app/api/documents/blob/preview/fallback/route.ts +++ b/src/app/api/documents/blob/preview/fallback/route.ts @@ -2,19 +2,24 @@ import { NextRequest, NextResponse } from 'next/server'; import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; -import { requireAuthContext } from '@/lib/server/auth'; -import { isValidDocumentId } from '@/lib/server/documents-blobstore'; +import { requireAuthContext } from '@/lib/server/auth/auth'; +import { + getDocumentRange, + isMissingBlobError as isMissingDocumentBlobError, + isValidDocumentId, +} from '@/lib/server/documents/blobstore'; import { getDocumentPreviewBuffer, - isMissingBlobError, -} from '@/lib/server/document-previews-blobstore'; + isMissingBlobError as isMissingPreviewBlobError, +} from '@/lib/server/documents/previews-blobstore'; import { ensureDocumentPreview, enqueueDocumentPreview, isPreviewableDocumentType, -} from '@/lib/server/document-previews'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; -import { isS3Configured } from '@/lib/server/s3'; +} from '@/lib/server/documents/previews'; +import { extractRawTextSnippet } from '@/lib/server/documents/text-snippets'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { isS3Configured } from '@/lib/server/storage/s3'; export const dynamic = 'force-dynamic'; @@ -34,6 +39,11 @@ function streamBuffer(buffer: Buffer): ReadableStream { }); } +function clampInt(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.max(min, Math.min(max, Math.trunc(value))); +} + export async function GET(req: NextRequest) { try { if (!isS3Configured()) return s3NotConfiguredResponse(); @@ -48,6 +58,7 @@ export async function GET(req: NextRequest) { const url = new URL(req.url); const id = (url.searchParams.get('id') || '').trim().toLowerCase(); + const snippetRequested = (url.searchParams.get('snippet') || '').trim() === '1'; const presignUrl = `/api/documents/blob/preview/presign?id=${encodeURIComponent(id)}`; const fallbackUrl = `/api/documents/blob/preview/fallback?id=${encodeURIComponent(id)}`; if (!isValidDocumentId(id)) { @@ -74,6 +85,28 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'Not found' }, { status: 404 }); } + if (snippetRequested) { + const maxChars = clampInt(Number.parseInt(url.searchParams.get('maxChars') || '1600', 10), 100, 8000); + const maxBytes = clampInt(Number.parseInt(url.searchParams.get('maxBytes') || '131072', 10), 4096, 1024 * 1024); + try { + const head = await getDocumentRange(id, 0, maxBytes - 1, testNamespace); + const decoded = new TextDecoder().decode(new Uint8Array(head)); + const snippet = extractRawTextSnippet(decoded, maxChars); + return NextResponse.json( + { snippet }, + { headers: { 'Cache-Control': 'no-store' } }, + ); + } catch (error) { + if (isMissingDocumentBlobError(error)) { + await db + .delete(documents) + .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds))); + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + throw error; + } + } + if (!isPreviewableDocumentType(doc.type)) { return NextResponse.json({ error: `Preview not supported for type ${doc.type}` }, { status: 415 }); } @@ -112,7 +145,7 @@ export async function GET(req: NextRequest) { }, }); } catch (error) { - if (isMissingBlobError(error)) { + if (isMissingPreviewBlobError(error)) { await enqueueDocumentPreview( { id: doc.id, diff --git a/src/app/api/documents/blob/preview/presign/route.ts b/src/app/api/documents/blob/preview/presign/route.ts index c4f9f84..409923c 100644 --- a/src/app/api/documents/blob/preview/presign/route.ts +++ b/src/app/api/documents/blob/preview/presign/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { presignDocumentPreviewGet } from '@/lib/server/document-previews-blobstore'; -import { ensureDocumentPreview } from '@/lib/server/document-previews'; +import { presignDocumentPreviewGet } from '@/lib/server/documents/previews-blobstore'; +import { ensureDocumentPreview } from '@/lib/server/documents/previews'; import { validatePreviewRequest } from '../utils'; export const dynamic = 'force-dynamic'; diff --git a/src/app/api/documents/blob/preview/utils.ts b/src/app/api/documents/blob/preview/utils.ts index d8d6ed2..e1784fe 100644 --- a/src/app/api/documents/blob/preview/utils.ts +++ b/src/app/api/documents/blob/preview/utils.ts @@ -2,11 +2,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; -import { requireAuthContext } from '@/lib/server/auth'; -import { isValidDocumentId } from '@/lib/server/documents-blobstore'; -import { isPreviewableDocumentType } from '@/lib/server/document-previews'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; -import { isS3Configured } from '@/lib/server/s3'; +import { requireAuthContext } from '@/lib/server/auth/auth'; +import { isValidDocumentId } from '@/lib/server/documents/blobstore'; +import { isPreviewableDocumentType } from '@/lib/server/documents/previews'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { isS3Configured } from '@/lib/server/storage/s3'; export function s3NotConfiguredResponse(): NextResponse { return NextResponse.json( diff --git a/src/app/api/documents/blob/route.ts b/src/app/api/documents/blob/route.ts deleted file mode 100644 index 11366dc..0000000 --- a/src/app/api/documents/blob/route.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { and, eq, inArray } from 'drizzle-orm'; -import { db } from '@/db'; -import { documents } from '@/db/schema'; -import { requireAuthContext } from '@/lib/server/auth'; -import { contentTypeForName } from '@/lib/server/library'; -import { extractRawTextSnippet } from '@/lib/text-snippets'; -import { - getDocumentBlob, - getDocumentRange, - isMissingBlobError, - isValidDocumentId, - presignGet, -} from '@/lib/server/documents-blobstore'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; -import { isS3Configured } from '@/lib/server/s3'; - -export const dynamic = 'force-dynamic'; - -function clampInt(value: number, min: number, max: number): number { - if (!Number.isFinite(value)) return min; - return Math.max(min, Math.min(max, Math.trunc(value))); -} - -function s3NotConfiguredResponse(): NextResponse { - return NextResponse.json( - { error: 'Documents storage is not configured. Set S3_* environment variables.' }, - { status: 503 }, - ); -} - -export async function GET(req: NextRequest) { - try { - if (!isS3Configured()) return s3NotConfiguredResponse(); - - const ctxOrRes = await requireAuthContext(req); - if (ctxOrRes instanceof Response) return ctxOrRes; - - const testNamespace = getOpenReaderTestNamespace(req.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const storageUserId = ctxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; - - const url = new URL(req.url); - const id = (url.searchParams.get('id') || '').trim().toLowerCase(); - const format = (url.searchParams.get('format') || '').toLowerCase().trim(); - const prefersProxy = (url.searchParams.get('proxy') || '').trim() === '1'; - if (!isValidDocumentId(id)) { - return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); - } - - const rows = (await db - .select({ id: documents.id, userId: documents.userId, name: documents.name, filePath: documents.filePath }) - .from(documents) - .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ - id: string; - userId: string; - name: string; - filePath: string; - }>; - - const doc = rows.find((row) => row.userId === storageUserId) ?? rows[0]; - if (!doc) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } - - const filename = doc.name || `${id}.bin`; - const responseType = contentTypeForName(filename); - - if (format === 'snippet') { - const maxChars = clampInt(Number.parseInt(url.searchParams.get('maxChars') || '1600', 10), 100, 8000); - const maxBytes = clampInt(Number.parseInt(url.searchParams.get('maxBytes') || '131072', 10), 4096, 1024 * 1024); - try { - const head = await getDocumentRange(id, 0, maxBytes - 1, testNamespace); - const decoded = new TextDecoder().decode(new Uint8Array(head)); - const snippet = extractRawTextSnippet(decoded, maxChars); - return NextResponse.json( - { snippet }, - { headers: { 'Cache-Control': 'no-store' } }, - ); - } catch (error) { - if (isMissingBlobError(error)) { - await db - .delete(documents) - .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds))); - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } - throw error; - } - } - - try { - if (!prefersProxy) { - const directUrl = await presignGet(id, testNamespace).catch(() => null); - if (directUrl) { - return NextResponse.redirect(directUrl, { - status: 307, - headers: { 'Cache-Control': 'no-store' }, - }); - } - } - - const content = await getDocumentBlob(id, testNamespace); - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(content)); - controller.close(); - }, - }); - return new NextResponse(stream, { - headers: { - 'Content-Type': responseType, - 'Cache-Control': 'no-store', - }, - }); - } catch (error) { - if (isMissingBlobError(error)) { - await db - .delete(documents) - .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds))); - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } - throw error; - } - } catch (error) { - console.error('Error loading document content:', error); - return NextResponse.json({ error: 'Failed to load document content' }, { status: 500 }); - } -} diff --git a/src/app/api/documents/blob/upload/fallback/route.ts b/src/app/api/documents/blob/upload/fallback/route.ts index 16ada73..7a4d657 100644 --- a/src/app/api/documents/blob/upload/fallback/route.ts +++ b/src/app/api/documents/blob/upload/fallback/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; -import { requireAuthContext } from '@/lib/server/auth'; -import { isValidDocumentId, putDocumentBlob } from '@/lib/server/documents-blobstore'; -import { isS3Configured } from '@/lib/server/s3'; -import { getOpenReaderTestNamespace } from '@/lib/server/test-namespace'; +import { requireAuthContext } from '@/lib/server/auth/auth'; +import { isValidDocumentId, putDocumentBlob } from '@/lib/server/documents/blobstore'; +import { isS3Configured } from '@/lib/server/storage/s3'; +import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; export const dynamic = 'force-dynamic'; diff --git a/src/app/api/documents/blob/upload/presign/route.ts b/src/app/api/documents/blob/upload/presign/route.ts index c1c803a..9f66b7e 100644 --- a/src/app/api/documents/blob/upload/presign/route.ts +++ b/src/app/api/documents/blob/upload/presign/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; -import { requireAuthContext } from '@/lib/server/auth'; -import { isValidDocumentId, presignPut } from '@/lib/server/documents-blobstore'; -import { getOpenReaderTestNamespace } from '@/lib/server/test-namespace'; -import { isS3Configured } from '@/lib/server/s3'; +import { requireAuthContext } from '@/lib/server/auth/auth'; +import { isValidDocumentId, presignPut } from '@/lib/server/documents/blobstore'; +import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; +import { isS3Configured } from '@/lib/server/storage/s3'; export const dynamic = 'force-dynamic'; diff --git a/src/app/api/documents/docx-to-pdf/upload/route.ts b/src/app/api/documents/docx-to-pdf/upload/route.ts index 4e79309..4a65228 100644 --- a/src/app/api/documents/docx-to-pdf/upload/route.ts +++ b/src/app/api/documents/docx-to-pdf/upload/route.ts @@ -5,14 +5,14 @@ import path from 'path'; import { existsSync } from 'fs'; import { randomUUID, createHash } from 'crypto'; import { pathToFileURL } from 'url'; -import { requireAuthContext } from '@/lib/server/auth'; +import { requireAuthContext } from '@/lib/server/auth/auth'; import { db } from '@/db'; import { documents } from '@/db/schema'; -import { safeDocumentName } from '@/lib/server/documents-utils'; -import { enqueueDocumentPreview } from '@/lib/server/document-previews'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; -import { isS3Configured } from '@/lib/server/s3'; -import { putDocumentBlob } from '@/lib/server/documents-blobstore'; +import { safeDocumentName } from '@/lib/server/documents/utils'; +import { enqueueDocumentPreview } from '@/lib/server/documents/previews'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { isS3Configured } from '@/lib/server/storage/s3'; +import { putDocumentBlob } from '@/lib/server/documents/blobstore'; const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp'); diff --git a/src/app/api/documents/library/content/route.ts b/src/app/api/documents/library/content/route.ts index 474fb48..3b1c89e 100644 --- a/src/app/api/documents/library/content/route.ts +++ b/src/app/api/documents/library/content/route.ts @@ -1,8 +1,8 @@ import { readFile, stat } from 'fs/promises'; import path from 'path'; import { NextRequest, NextResponse } from 'next/server'; -import { contentTypeForName, decodeLibraryId, isPathWithinRoot, parseLibraryRoots } from '@/lib/server/library'; -import { auth } from '@/lib/server/auth'; +import { contentTypeForName, decodeLibraryId, isPathWithinRoot, parseLibraryRoots } from '@/lib/server/storage/library-mount'; +import { auth } from '@/lib/server/auth/auth'; export const dynamic = 'force-dynamic'; diff --git a/src/app/api/documents/library/route.ts b/src/app/api/documents/library/route.ts index 047d9ac..d152a94 100644 --- a/src/app/api/documents/library/route.ts +++ b/src/app/api/documents/library/route.ts @@ -2,9 +2,9 @@ import type { Dirent } from 'fs'; import { readdir, stat } from 'fs/promises'; import path from 'path'; import { NextRequest, NextResponse } from 'next/server'; -import { parseLibraryRoots } from '@/lib/server/library'; +import { parseLibraryRoots } from '@/lib/server/storage/library-mount'; import type { DocumentType } from '@/types/documents'; -import { auth } from '@/lib/server/auth'; +import { auth } from '@/lib/server/auth/auth'; export const dynamic = 'force-dynamic'; diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 5aa3e0f..38eb3b9 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -2,16 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'; import { and, count, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; -import { requireAuthContext } from '@/lib/server/auth'; -import { safeDocumentName, toDocumentTypeFromName } from '@/lib/server/documents-utils'; +import { requireAuthContext } from '@/lib/server/auth/auth'; +import { safeDocumentName, toDocumentTypeFromName } from '@/lib/server/documents/utils'; import { cleanupDocumentPreviewArtifacts, deleteDocumentPreviewRows, enqueueDocumentPreview, -} from '@/lib/server/document-previews'; -import { deleteDocumentBlob, headDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents-blobstore'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; -import { isS3Configured } from '@/lib/server/s3'; +} from '@/lib/server/documents/previews'; +import { deleteDocumentBlob, headDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { isS3Configured } from '@/lib/server/storage/s3'; import type { BaseDocument, DocumentType } from '@/types/documents'; export const dynamic = 'force-dynamic'; diff --git a/src/app/api/rate-limit/status/route.ts b/src/app/api/rate-limit/status/route.ts index 021775b..6e78adb 100644 --- a/src/app/api/rate-limit/status/route.ts +++ b/src/app/api/rate-limit/status/route.ts @@ -1,10 +1,10 @@ import { NextResponse, type NextRequest } from 'next/server'; -import { auth } from '@/lib/server/auth'; -import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limiter'; +import { auth } from '@/lib/server/auth/auth'; +import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter'; import { headers } from 'next/headers'; -import { isAuthEnabled } from '@/lib/server/auth-config'; -import { getClientIp } from '@/lib/server/request-ip'; -import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/device-id'; +import { isAuthEnabled } from '@/lib/server/auth/config'; +import { getClientIp } from '@/lib/server/rate-limit/request-ip'; +import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id'; export const dynamic = 'force-dynamic'; diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index de3c167..81e2659 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -1,17 +1,17 @@ import { NextRequest, NextResponse } from 'next/server'; import OpenAI from 'openai'; import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs'; -import { isKokoroModel } from '@/lib/kokoro'; +import { isKokoroModel } from '@/lib/shared/kokoro'; import { LRUCache } from 'lru-cache'; import { createHash } from 'crypto'; import type { TTSRequestPayload } from '@/types/client'; import type { TTSError, TTSAudioBuffer } from '@/types/tts'; import { headers } from 'next/headers'; -import { auth } from '@/lib/server/auth'; -import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limiter'; -import { isAuthEnabled } from '@/lib/server/auth-config'; -import { getClientIp } from '@/lib/server/request-ip'; -import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/device-id'; +import { auth } from '@/lib/server/auth/auth'; +import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter'; +import { isAuthEnabled } from '@/lib/server/auth/config'; +import { getClientIp } from '@/lib/server/rate-limit/request-ip'; +import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id'; function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, didCreate: boolean) { if (didCreate && deviceId) { diff --git a/src/app/api/tts/voices/route.ts b/src/app/api/tts/voices/route.ts index 2fc45c8..221f1b7 100644 --- a/src/app/api/tts/voices/route.ts +++ b/src/app/api/tts/voices/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { isKokoroModel } from '@/lib/kokoro'; -import { auth } from '@/lib/server/auth'; +import { isKokoroModel } from '@/lib/shared/kokoro'; +import { auth } from '@/lib/server/auth/auth'; const OPENAI_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']; const GPT4O_MINI_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer']; diff --git a/src/app/api/user/claim/route.ts b/src/app/api/user/claim/route.ts index 344c88d..c958c90 100644 --- a/src/app/api/user/claim/route.ts +++ b/src/app/api/user/claim/route.ts @@ -1,10 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; -import { claimAnonymousData } from '@/lib/server/claim-data'; -import { auth } from '@/lib/server/auth'; +import { claimAnonymousData } from '@/lib/server/user/claim-data'; +import { auth } from '@/lib/server/auth/auth'; import { db } from '@/db'; import { audiobooks, documents, userDocumentProgress, userPreferences } from '@/db/schema'; import { count, eq, ne } from 'drizzle-orm'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; async function checkClaimMigrationReadiness(): Promise { const [legacyRows] = await db diff --git a/src/app/api/user/export/route.ts b/src/app/api/user/export/route.ts index bf1eac4..ad633b1 100644 --- a/src/app/api/user/export/route.ts +++ b/src/app/api/user/export/route.ts @@ -1,15 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { PassThrough, Readable } from 'stream'; -import { auth } from '@/lib/server/auth'; +import { auth } from '@/lib/server/auth/auth'; import { db } from '@/db'; import { documents, audiobooks, audiobookChapters, userPreferences, userDocumentProgress, userTtsChars } from '@/db/schema'; import { and, desc, eq, inArray } from 'drizzle-orm'; import archiver from 'archiver'; -import { appendUserExportArchive } from '@/lib/server/user-export'; -import { getDocumentBlobStream } from '@/lib/server/documents-blobstore'; -import { getAudiobookObjectStream, listAudiobookObjects } from '@/lib/server/audiobooks-blobstore'; -import { isS3Configured } from '@/lib/server/s3'; -import { getOpenReaderTestNamespace } from '@/lib/server/test-namespace'; +import { appendUserExportArchive } from '@/lib/server/user/data-export'; +import { getDocumentBlobStream } from '@/lib/server/documents/blobstore'; +import { getAudiobookObjectStream, listAudiobookObjects } from '@/lib/server/audiobooks/blobstore'; +import { isS3Configured } from '@/lib/server/storage/s3'; +import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; export const dynamic = 'force-dynamic'; diff --git a/src/app/api/user/state/preferences/route.ts b/src/app/api/user/state/preferences/route.ts index 8ed8f3c..bc9bfb4 100644 --- a/src/app/api/user/state/preferences/route.ts +++ b/src/app/api/user/state/preferences/route.ts @@ -3,7 +3,7 @@ import { eq } from 'drizzle-orm'; import { db } from '@/db'; import { userPreferences } from '@/db/schema'; import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state'; -import { resolveUserStateScope } from '@/lib/server/user-state-scope'; +import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope'; export const dynamic = 'force-dynamic'; diff --git a/src/app/api/user/state/progress/route.ts b/src/app/api/user/state/progress/route.ts index 4eb6f9a..69a75b3 100644 --- a/src/app/api/user/state/progress/route.ts +++ b/src/app/api/user/state/progress/route.ts @@ -3,8 +3,8 @@ import { and, eq, sql } from 'drizzle-orm'; import { db } from '@/db'; import { userDocumentProgress } from '@/db/schema'; import type { ReaderType } from '@/types/user-state'; -import { isValidDocumentId } from '@/lib/server/documents-blobstore'; -import { resolveUserStateScope } from '@/lib/server/user-state-scope'; +import { isValidDocumentId } from '@/lib/server/documents/blobstore'; +import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope'; export const dynamic = 'force-dynamic'; diff --git a/src/app/api/whisper/route.ts b/src/app/api/whisper/route.ts index 44364f3..d43f4b4 100644 --- a/src/app/api/whisper/route.ts +++ b/src/app/api/whisper/route.ts @@ -5,9 +5,9 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { spawn } from 'child_process'; import type { TTSSentenceAlignment, TTSAudioBytes, TTSAudioBuffer } from '@/types/tts'; -import { preprocessSentenceForAudio } from '@/lib/nlp'; -import { auth } from '@/lib/server/auth'; -import { getFFmpegPath } from '@/lib/server/ffmpeg-bin'; +import { preprocessSentenceForAudio } from '@/lib/shared/nlp'; +import { auth } from '@/lib/server/auth/auth'; +import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin'; export const runtime = 'nodejs'; diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx index 02caf71..d3d9fb8 100644 --- a/src/components/AudiobookExportModal.tsx +++ b/src/components/AudiobookExportModal.tsx @@ -18,7 +18,7 @@ import { deleteAudiobook, downloadAudiobookChapter, downloadAudiobook -} from '@/lib/client'; +} from '@/lib/client/api/audiobooks'; import type { AudiobookGenerationSettings } from '@/types/client'; interface AudiobookExportModalProps { isOpen: boolean; diff --git a/src/components/ConsentAwareAnalytics.tsx b/src/components/ConsentAwareAnalytics.tsx index d450a09..b627e32 100644 --- a/src/components/ConsentAwareAnalytics.tsx +++ b/src/components/ConsentAwareAnalytics.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { Analytics } from '@vercel/analytics/next'; -import { CONSENT_CHANGED_EVENT, disableAnalytics, getConsentState } from '@/lib/analytics'; +import { CONSENT_CHANGED_EVENT, disableAnalytics, getConsentState } from '@/lib/client/analytics'; export function ConsentAwareAnalytics() { const [enabled, setEnabled] = useState(false); diff --git a/src/components/CookieConsentBanner.tsx b/src/components/CookieConsentBanner.tsx index 2f88eb5..fb796c6 100644 --- a/src/components/CookieConsentBanner.tsx +++ b/src/components/CookieConsentBanner.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; -import { getConsentState, setConsentState } from '@/lib/analytics'; +import { getConsentState, setConsentState } from '@/lib/client/analytics'; import { Transition } from '@headlessui/react'; export function CookieConsentBanner() { diff --git a/src/components/PrivacyModal.tsx b/src/components/PrivacyModal.tsx index 68afbd4..f1b2c7a 100644 --- a/src/components/PrivacyModal.tsx +++ b/src/components/PrivacyModal.tsx @@ -9,7 +9,7 @@ import { TransitionChild, Button, } from '@headlessui/react'; -import { updateAppConfig, getAppConfig } from '@/lib/dexie'; +import { updateAppConfig, getAppConfig } from '@/lib/client/dexie'; interface PrivacyModalProps { onAccept?: () => void; diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index beaf3c9..0f1a999 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -23,7 +23,7 @@ import Link from 'next/link'; import { useTheme } from '@/contexts/ThemeContext'; import { useConfig } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons'; -import { getAppConfig, getFirstVisit, setFirstVisit } from '@/lib/dexie'; +import { getAppConfig, getFirstVisit, setFirstVisit } from '@/lib/client/dexie'; import { useDocuments } from '@/contexts/DocumentContext'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ProgressPopup } from '@/components/ProgressPopup'; @@ -31,14 +31,14 @@ import { useTimeEstimation } from '@/hooks/useTimeEstimation'; import { THEMES } from '@/contexts/ThemeContext'; import { DocumentSelectionModal } from '@/components/documents/DocumentSelectionModal'; import { BaseDocument } from '@/types/documents'; -import { getAuthClient } from '@/lib/auth-client'; +import { getAuthClient } from '@/lib/client/auth-client'; import { useAuthSession } from '@/hooks/useAuthSession'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useRouter } from 'next/navigation'; import { showPrivacyModal } from '@/components/PrivacyModal'; -import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client-documents'; -import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/document-cache'; -import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/document-preview-cache'; +import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents'; +import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents'; +import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews'; const enableDestructiveDelete = process.env.NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS !== 'false'; const showAllDeepInfra = process.env.NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS !== 'false'; diff --git a/src/components/auth/AuthLoader.tsx b/src/components/auth/AuthLoader.tsx index ced62af..05a0374 100644 --- a/src/components/auth/AuthLoader.tsx +++ b/src/components/auth/AuthLoader.tsx @@ -5,7 +5,7 @@ import type { BetterFetchError } from 'better-auth/react'; import { usePathname, useRouter } from 'next/navigation'; import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useAuthSession } from '@/hooks/useAuthSession'; -import { getAuthClient } from '@/lib/auth-client'; +import { getAuthClient } from '@/lib/client/auth-client'; import { LoadingSpinner } from '@/components/Spinner'; function sleep(ms: number) { diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx index 1b9ee66..f09d98b 100644 --- a/src/components/auth/UserMenu.tsx +++ b/src/components/auth/UserMenu.tsx @@ -4,7 +4,7 @@ import { Button } from '@headlessui/react'; import Link from 'next/link'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useAuthSession } from '@/hooks/useAuthSession'; -import { getAuthClient } from '@/lib/auth-client'; +import { getAuthClient } from '@/lib/client/auth-client'; import { useRouter } from 'next/navigation'; export function UserMenu({ className = '' }: { className?: string }) { diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx index 5d1417d..9ebd7bc 100644 --- a/src/components/doclist/DocumentList.tsx +++ b/src/components/doclist/DocumentList.tsx @@ -5,7 +5,7 @@ import { useDocuments } from '@/contexts/DocumentContext'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { DocumentType, DocumentListDocument, Folder, DocumentListState, SortBy, SortDirection } from '@/types/documents'; -import { getDocumentListState, saveDocumentListState } from '@/lib/dexie'; +import { getDocumentListState, saveDocumentListState } from '@/lib/client/dexie'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { DocumentListItem } from '@/components/doclist/DocumentListItem'; import { DocumentFolder } from '@/components/doclist/DocumentFolder'; diff --git a/src/components/doclist/DocumentPreview.tsx b/src/components/doclist/DocumentPreview.tsx index 992dd33..6249b03 100644 --- a/src/components/doclist/DocumentPreview.tsx +++ b/src/components/doclist/DocumentPreview.tsx @@ -5,13 +5,13 @@ import { documentPreviewFallbackUrl, getDocumentContentSnippet, getDocumentPreviewStatus, -} from '@/lib/client-documents'; +} from '@/lib/client/api/documents'; import { getInMemoryDocumentPreviewUrl, getPersistedDocumentPreviewUrl, primeDocumentPreviewCache, setInMemoryDocumentPreviewUrl, -} from '@/lib/document-preview-cache'; +} from '@/lib/client/cache/previews'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; diff --git a/src/components/documents/DexieMigrationModal.tsx b/src/components/documents/DexieMigrationModal.tsx index 2b57554..5f565b5 100644 --- a/src/components/documents/DexieMigrationModal.tsx +++ b/src/components/documents/DexieMigrationModal.tsx @@ -2,11 +2,11 @@ import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Button } from '@headlessui/react'; -import { getAppConfig, getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/dexie'; -import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client-documents'; +import { getAppConfig, getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie'; +import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents'; import { useDocuments } from '@/contexts/DocumentContext'; import type { BaseDocument } from '@/types/documents'; -import { cacheStoredDocumentFromBytes } from '@/lib/document-cache'; +import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents'; export function DexieMigrationModal() { const { refreshDocuments } = useDocuments(); diff --git a/src/components/documents/DocumentUploader.tsx b/src/components/documents/DocumentUploader.tsx index 0a714d2..a34cdeb 100644 --- a/src/components/documents/DocumentUploader.tsx +++ b/src/components/documents/DocumentUploader.tsx @@ -4,7 +4,7 @@ import { useState, useCallback } from 'react'; import { useDropzone } from 'react-dropzone'; import { UploadIcon } from '@/components/icons/Icons'; import { useDocuments } from '@/contexts/DocumentContext'; -import { uploadDocxAsPdf } from '@/lib/client-documents'; +import { uploadDocxAsPdf } from '@/lib/client/api/documents'; const enableDocx = process.env.NEXT_PUBLIC_ENABLE_DOCX_CONVERSION !== 'false'; diff --git a/src/components/player/VoicesControlBase.tsx b/src/components/player/VoicesControlBase.tsx index b1dd415..5bceae8 100644 --- a/src/components/player/VoicesControlBase.tsx +++ b/src/components/player/VoicesControlBase.tsx @@ -8,7 +8,7 @@ import { } from '@headlessui/react'; import { ChevronUpDownIcon, AudioWaveIcon } from '@/components/icons/Icons'; import { useEffect, useMemo, useState } from 'react'; -import { buildKokoroVoiceString, getMaxVoicesForProvider, isKokoroModel, parseKokoroVoiceNames } from '@/lib/kokoro'; +import { buildKokoroVoiceString, getMaxVoicesForProvider, isKokoroModel, parseKokoroVoiceNames } from '@/lib/shared/kokoro'; export function VoicesControlBase({ availableVoices, diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index c5d2c5e..70b8a18 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -2,9 +2,9 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react'; import { useLiveQuery } from 'dexie-react-hooks'; -import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/dexie'; +import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/client/dexie'; import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config'; -import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client-user-state'; +import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state'; import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state'; import { useAuthSession } from '@/hooks/useAuthSession'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; diff --git a/src/contexts/DocumentContext.tsx b/src/contexts/DocumentContext.tsx index 6290578..8c1ae50 100644 --- a/src/contexts/DocumentContext.tsx +++ b/src/contexts/DocumentContext.tsx @@ -2,8 +2,8 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, ReactNode } from 'react'; import type { BaseDocument } from '@/types/documents'; -import { listDocuments, uploadDocuments, deleteDocuments } from '@/lib/client-documents'; -import { putCachedEpub, putCachedHtml, putCachedPdf, evictCachedEpub, evictCachedHtml, evictCachedPdf } from '@/lib/document-cache'; +import { listDocuments, uploadDocuments, deleteDocuments } from '@/lib/client/api/documents'; +import { putCachedEpub, putCachedHtml, putCachedPdf, evictCachedEpub, evictCachedHtml, evictCachedPdf } from '@/lib/client/cache/documents'; import { useAuthSession } from '@/hooks/useAuthSession'; interface DocumentContextType { diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index 5ba5c1c..77de036 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -16,16 +16,16 @@ import type { NavItem } from 'epubjs'; import type { SpineItem } from 'epubjs/types/section'; import type { Book, Rendition } from 'epubjs'; -import { setLastDocumentLocation } from '@/lib/dexie'; -import { scheduleDocumentProgressSync } from '@/lib/client-user-state'; -import { getDocumentMetadata } from '@/lib/client-documents'; -import { ensureCachedDocument } from '@/lib/document-cache'; +import { setLastDocumentLocation } from '@/lib/client/dexie'; +import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state'; +import { getDocumentMetadata } from '@/lib/client/api/documents'; +import { ensureCachedDocument } from '@/lib/client/cache/documents'; import { useTTS } from '@/contexts/TTSContext'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; -import { createRangeCfi } from '@/lib/epub'; +import { createRangeCfi } from '@/lib/client/epub'; import { useParams } from 'next/navigation'; import { useConfig } from './ConfigContext'; -import { withRetry, getAudiobookStatus, generateTTS, createAudiobookChapter } from '@/lib/client'; +import { withRetry, getAudiobookStatus, generateTTS, createAudiobookChapter } from '@/lib/client/api/audiobooks'; import { CmpStr } from 'cmpstr'; import type { TTSSentenceAlignment, diff --git a/src/contexts/HTMLContext.tsx b/src/contexts/HTMLContext.tsx index caf8e26..350d0c7 100644 --- a/src/contexts/HTMLContext.tsx +++ b/src/contexts/HTMLContext.tsx @@ -10,8 +10,8 @@ import { useEffect, useRef, } from 'react'; -import { getDocumentMetadata } from '@/lib/client-documents'; -import { ensureCachedDocument } from '@/lib/document-cache'; +import { getDocumentMetadata } from '@/lib/client/api/documents'; +import { ensureCachedDocument } from '@/lib/client/cache/documents'; import { useTTS } from '@/contexts/TTSContext'; interface HTMLContextType { diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index bdcabf9..eff105c 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -27,19 +27,19 @@ import { import type { PDFDocumentProxy } from 'pdfjs-dist'; -import { getDocumentMetadata } from '@/lib/client-documents'; -import { ensureCachedDocument } from '@/lib/document-cache'; +import { getDocumentMetadata } from '@/lib/client/api/documents'; +import { ensureCachedDocument } from '@/lib/client/cache/documents'; import { useTTS } from '@/contexts/TTSContext'; import { useConfig } from '@/contexts/ConfigContext'; -import { normalizeTextForTts } from '@/lib/nlp'; -import { withRetry, getAudiobookStatus, generateTTS, createAudiobookChapter } from '@/lib/client'; +import { normalizeTextForTts } from '@/lib/shared/nlp'; +import { withRetry, getAudiobookStatus, generateTTS, createAudiobookChapter } from '@/lib/client/api/audiobooks'; import { extractTextFromPDF, highlightPattern, clearHighlights, clearWordHighlights, highlightWordIndex, -} from '@/lib/pdf'; +} from '@/lib/client/pdf'; import type { TTSSentenceAlignment, diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 00f2e49..2dde974 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -34,12 +34,12 @@ import { useAudioCache } from '@/hooks/audio/useAudioCache'; import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement'; import { useMediaSession } from '@/hooks/audio/useMediaSession'; import { useAudioContext } from '@/hooks/audio/useAudioContext'; -import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/dexie'; -import { getDocumentProgress, scheduleDocumentProgressSync } from '@/lib/client-user-state'; +import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/client/dexie'; +import { getDocumentProgress, scheduleDocumentProgressSync } from '@/lib/client/api/user-state'; import { useBackgroundState } from '@/hooks/audio/useBackgroundState'; -import { withRetry, generateTTS, alignAudio } from '@/lib/client'; -import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/nlp'; -import { isKokoroModel } from '@/lib/kokoro'; +import { withRetry, generateTTS, alignAudio } from '@/lib/client/api/audiobooks'; +import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/shared/nlp'; +import { isKokoroModel } from '@/lib/shared/kokoro'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import type { TTSLocation, diff --git a/src/db/schema_postgres.ts b/src/db/schema_postgres.ts index 5afc567..864845d 100644 --- a/src/db/schema_postgres.ts +++ b/src/db/schema_postgres.ts @@ -10,11 +10,11 @@ export const documents = pgTable('documents', { lastModified: bigint('last_modified', { mode: 'number' }).notNull(), filePath: text('file_path').notNull(), createdAt: timestamp('created_at').defaultNow(), -}, (table) => ({ - pk: primaryKey({ columns: [table.id, table.userId] }), - userIdIdx: index('idx_documents_user_id').on(table.userId), - userIdLastModifiedIdx: index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified), -})); +}, (table) => [ + primaryKey({ columns: [table.id, table.userId] }), + index('idx_documents_user_id').on(table.userId), + index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified), +]); export const audiobooks = pgTable('audiobooks', { id: text('id').notNull(), @@ -25,9 +25,9 @@ export const audiobooks = pgTable('audiobooks', { coverPath: text('cover_path'), duration: real('duration').default(0), createdAt: timestamp('created_at').defaultNow(), -}, (table) => ({ - pk: primaryKey({ columns: [table.id, table.userId] }), -})); +}, (table) => [ + primaryKey({ columns: [table.id, table.userId] }), +]); export const audiobookChapters = pgTable('audiobook_chapters', { id: text('id').notNull(), @@ -38,13 +38,13 @@ export const audiobookChapters = pgTable('audiobook_chapters', { duration: real('duration').default(0), filePath: text('file_path').notNull(), format: text('format').notNull(), // mp3, m4b -}, (table) => ({ - pk: primaryKey({ columns: [table.id, table.userId] }), - bookFk: foreignKey({ +}, (table) => [ + primaryKey({ columns: [table.id, table.userId] }), + foreignKey({ columns: [table.bookId, table.userId], foreignColumns: [audiobooks.id, audiobooks.userId], }).onDelete('cascade'), -})); +]); // Auth tables (user, session, account, verification) are managed by Better Auth. // They are created/migrated via `@better-auth/cli migrate` and should NOT be @@ -56,10 +56,10 @@ export const userTtsChars = pgTable("user_tts_chars", { charCount: bigint('char_count', { mode: 'number' }).default(0), createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at').defaultNow(), -}, (table) => ({ - pk: primaryKey({ columns: [table.userId, table.date] }), - dateIdx: index('idx_user_tts_chars_date').on(table.date), -})); +}, (table) => [ + primaryKey({ columns: [table.userId, table.date] }), + index('idx_user_tts_chars_date').on(table.date), +]); export const userPreferences = pgTable('user_preferences', { userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }), @@ -78,10 +78,10 @@ export const userDocumentProgress = pgTable('user_document_progress', { clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0), createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at').defaultNow(), -}, (table) => ({ - pk: primaryKey({ columns: [table.userId, table.documentId] }), - userUpdatedIdx: index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt), -})); +}, (table) => [ + primaryKey({ columns: [table.userId, table.documentId] }), + index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt), +]); export const documentPreviews = pgTable('document_previews', { documentId: text('document_id').notNull(), @@ -101,7 +101,7 @@ export const documentPreviews = pgTable('document_previews', { lastError: text('last_error'), createdAtMs: bigint('created_at_ms', { mode: 'number' }).notNull().default(0), updatedAtMs: bigint('updated_at_ms', { mode: 'number' }).notNull().default(0), -}, (table) => ({ - pk: primaryKey({ columns: [table.documentId, table.namespace, table.variant] }), - statusLeaseIdx: index('idx_document_previews_status_lease').on(table.status, table.leaseUntilMs), -})); +}, (table) => [ + primaryKey({ columns: [table.documentId, table.namespace, table.variant] }), + index('idx_document_previews_status_lease').on(table.status, table.leaseUntilMs), +]); diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index 9b5a7e0..bbac0d9 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -11,11 +11,11 @@ export const documents = sqliteTable('documents', { lastModified: integer('last_modified').notNull(), filePath: text('file_path').notNull(), createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`), -}, (table) => ({ - pk: primaryKey({ columns: [table.id, table.userId] }), - userIdIdx: index('idx_documents_user_id').on(table.userId), - userIdLastModifiedIdx: index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified), -})); +}, (table) => [ + primaryKey({ columns: [table.id, table.userId] }), + index('idx_documents_user_id').on(table.userId), + index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified), +]); export const audiobooks = sqliteTable('audiobooks', { id: text('id').notNull(), @@ -26,9 +26,9 @@ export const audiobooks = sqliteTable('audiobooks', { coverPath: text('cover_path'), duration: real('duration').default(0), createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`), -}, (table) => ({ - pk: primaryKey({ columns: [table.id, table.userId] }), -})); +}, (table) => [ + primaryKey({ columns: [table.id, table.userId] }), +]); export const audiobookChapters = sqliteTable('audiobook_chapters', { id: text('id').notNull(), @@ -39,13 +39,13 @@ export const audiobookChapters = sqliteTable('audiobook_chapters', { duration: real('duration').default(0), filePath: text('file_path').notNull(), format: text('format').notNull(), // mp3, m4b -}, (table) => ({ - pk: primaryKey({ columns: [table.id, table.userId] }), - bookFk: foreignKey({ +}, (table) => [ + primaryKey({ columns: [table.id, table.userId] }), + foreignKey({ columns: [table.bookId, table.userId], foreignColumns: [audiobooks.id, audiobooks.userId], }).onDelete('cascade'), -})); +]); // Auth tables (user, session, account, verification) are managed by Better Auth. // They are created/migrated via `@better-auth/cli migrate` and should NOT be @@ -57,10 +57,10 @@ export const userTtsChars = sqliteTable("user_tts_chars", { charCount: integer('char_count').default(0), createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`), updatedAt: integer('updated_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`), -}, (table) => ({ - pk: primaryKey({ columns: [table.userId, table.date] }), - dateIdx: index('idx_user_tts_chars_date').on(table.date), -})); +}, (table) => [ + primaryKey({ columns: [table.userId, table.date] }), + index('idx_user_tts_chars_date').on(table.date), +]); export const userPreferences = sqliteTable('user_preferences', { userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }), @@ -79,10 +79,10 @@ export const userDocumentProgress = sqliteTable('user_document_progress', { clientUpdatedAtMs: integer('client_updated_at_ms').notNull().default(0), createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`), updatedAt: integer('updated_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`), -}, (table) => ({ - pk: primaryKey({ columns: [table.userId, table.documentId] }), - userUpdatedIdx: index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt), -})); +}, (table) => [ + primaryKey({ columns: [table.userId, table.documentId] }), + index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt), +]); export const documentPreviews = sqliteTable('document_previews', { documentId: text('document_id').notNull(), @@ -102,7 +102,7 @@ export const documentPreviews = sqliteTable('document_previews', { lastError: text('last_error'), createdAtMs: integer('created_at_ms').notNull().default(0), updatedAtMs: integer('updated_at_ms').notNull().default(0), -}, (table) => ({ - pk: primaryKey({ columns: [table.documentId, table.namespace, table.variant] }), - statusLeaseIdx: index('idx_document_previews_status_lease').on(table.status, table.leaseUntilMs), -})); +}, (table) => [ + primaryKey({ columns: [table.documentId, table.namespace, table.variant] }), + index('idx_document_previews_status_lease').on(table.status, table.leaseUntilMs), +]); diff --git a/src/hooks/audio/useVoiceManagement.ts b/src/hooks/audio/useVoiceManagement.ts index 67f0d6f..51a62d2 100644 --- a/src/hooks/audio/useVoiceManagement.ts +++ b/src/hooks/audio/useVoiceManagement.ts @@ -1,7 +1,7 @@ 'use client'; import { useState, useCallback } from 'react'; -import { getVoices } from '@/lib/client'; +import { getVoices } from '@/lib/client/api/audiobooks'; const DEFAULT_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer']; diff --git a/src/hooks/epub/useEPUBDocuments.ts b/src/hooks/epub/useEPUBDocuments.ts index 6e58477..989d009 100644 --- a/src/hooks/epub/useEPUBDocuments.ts +++ b/src/hooks/epub/useEPUBDocuments.ts @@ -2,9 +2,9 @@ import { useCallback } from 'react'; import { useLiveQuery } from 'dexie-react-hooks'; -import { db } from '@/lib/dexie'; +import { db } from '@/lib/client/dexie'; import type { EPUBDocument } from '@/types/documents'; -import { sha256HexFromArrayBuffer } from '@/lib/sha256'; +import { sha256HexFromArrayBuffer } from '@/lib/client/sha256'; export function useEPUBDocuments() { const documents = useLiveQuery( diff --git a/src/hooks/epub/useEPUBResize.ts b/src/hooks/epub/useEPUBResize.ts index aec59bf..2a215d3 100644 --- a/src/hooks/epub/useEPUBResize.ts +++ b/src/hooks/epub/useEPUBResize.ts @@ -1,5 +1,5 @@ import { useEffect, RefObject, useState } from 'react'; -import { debounce } from '@/lib/pdf'; +import { debounce } from '@/lib/client/pdf'; export function useEPUBResize(containerRef: RefObject) { const [isResizing, setIsResizing] = useState(false); diff --git a/src/hooks/html/useHTMLDocuments.ts b/src/hooks/html/useHTMLDocuments.ts index b5d620c..2714c05 100644 --- a/src/hooks/html/useHTMLDocuments.ts +++ b/src/hooks/html/useHTMLDocuments.ts @@ -2,9 +2,9 @@ import { useCallback } from 'react'; import { useLiveQuery } from 'dexie-react-hooks'; -import { db } from '@/lib/dexie'; +import { db } from '@/lib/client/dexie'; import type { HTMLDocument } from '@/types/documents'; -import { sha256HexFromString } from '@/lib/sha256'; +import { sha256HexFromString } from '@/lib/client/sha256'; export function useHTMLDocuments() { const documents = useLiveQuery( diff --git a/src/hooks/pdf/usePDFDocuments.ts b/src/hooks/pdf/usePDFDocuments.ts index 04047e2..44f0668 100644 --- a/src/hooks/pdf/usePDFDocuments.ts +++ b/src/hooks/pdf/usePDFDocuments.ts @@ -2,9 +2,9 @@ import { useCallback } from 'react'; import { useLiveQuery } from 'dexie-react-hooks'; -import { db } from '@/lib/dexie'; +import { db } from '@/lib/client/dexie'; import type { PDFDocument } from '@/types/documents'; -import { sha256HexFromArrayBuffer } from '@/lib/sha256'; +import { sha256HexFromArrayBuffer } from '@/lib/client/sha256'; export function usePDFDocuments() { const documents = useLiveQuery( diff --git a/src/hooks/pdf/usePDFResize.ts b/src/hooks/pdf/usePDFResize.ts index 8bfa9ad..7b5a3f5 100644 --- a/src/hooks/pdf/usePDFResize.ts +++ b/src/hooks/pdf/usePDFResize.ts @@ -1,5 +1,5 @@ import { RefObject, useState, useEffect } from 'react'; -import { debounce } from '@/lib/pdf'; +import { debounce } from '@/lib/client/pdf'; interface UsePDFResizeResult { containerWidth: number; diff --git a/src/hooks/useAuthSession.ts b/src/hooks/useAuthSession.ts index c9a554b..40bad42 100644 --- a/src/hooks/useAuthSession.ts +++ b/src/hooks/useAuthSession.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; -import { getAuthClient } from '@/lib/auth-client'; +import { getAuthClient } from '@/lib/client/auth-client'; type SessionHookResult = ReturnType['useSession']>; diff --git a/src/lib/analytics.ts b/src/lib/client/analytics.ts similarity index 100% rename from src/lib/analytics.ts rename to src/lib/client/analytics.ts diff --git a/src/lib/client.ts b/src/lib/client/api/audiobooks.ts similarity index 100% rename from src/lib/client.ts rename to src/lib/client/api/audiobooks.ts diff --git a/src/lib/client-documents.ts b/src/lib/client/api/documents.ts similarity index 98% rename from src/lib/client-documents.ts rename to src/lib/client/api/documents.ts index 092db4e..5cd5697 100644 --- a/src/lib/client-documents.ts +++ b/src/lib/client/api/documents.ts @@ -1,4 +1,4 @@ -import { sha256HexFromArrayBuffer } from '@/lib/sha256'; +import { sha256HexFromArrayBuffer } from '@/lib/client/sha256'; import type { BaseDocument, DocumentType } from '@/types/documents'; export type UploadSource = { @@ -243,11 +243,11 @@ export async function getDocumentContentSnippet( ): Promise { const params = new URLSearchParams(); params.set('id', id); - params.set('format', 'snippet'); + params.set('snippet', '1'); if (typeof options?.maxChars === 'number') params.set('maxChars', String(options.maxChars)); if (typeof options?.maxBytes === 'number') params.set('maxBytes', String(options.maxBytes)); - const res = await fetch(`/api/documents/blob?${params.toString()}`, { signal: options?.signal }); + const res = await fetch(`/api/documents/blob/preview/fallback?${params.toString()}`, { signal: options?.signal }); if (!res.ok) { const data = (await res.json().catch(() => null)) as { error?: string } | null; throw new Error(data?.error || `Failed to load content snippet (status ${res.status})`); diff --git a/src/lib/client-user-state.ts b/src/lib/client/api/user-state.ts similarity index 100% rename from src/lib/client-user-state.ts rename to src/lib/client/api/user-state.ts diff --git a/src/lib/auth-client.ts b/src/lib/client/auth-client.ts similarity index 100% rename from src/lib/auth-client.ts rename to src/lib/client/auth-client.ts diff --git a/src/lib/document-cache.ts b/src/lib/client/cache/documents.ts similarity index 85% rename from src/lib/document-cache.ts rename to src/lib/client/cache/documents.ts index 00e6eea..b552c0a 100644 --- a/src/lib/document-cache.ts +++ b/src/lib/client/cache/documents.ts @@ -1,5 +1,5 @@ import type { BaseDocument, EPUBDocument, HTMLDocument, PDFDocument } from '@/types/documents'; -import { downloadDocumentContent } from '@/lib/client-documents'; +import { downloadDocumentContent } from '@/lib/client/api/documents'; export type DocumentCacheBackend = { get: (meta: BaseDocument) => Promise; @@ -42,12 +42,12 @@ export async function ensureCachedDocumentCore( } export async function getCachedPdf(id: string): Promise { - const { getPdfDocument } = await import('@/lib/dexie'); + const { getPdfDocument } = await import('@/lib/client/dexie'); return (await getPdfDocument(id)) ?? null; } export async function putCachedPdf(meta: BaseDocument, data: ArrayBuffer): Promise { - const { addPdfDocument } = await import('@/lib/dexie'); + const { addPdfDocument } = await import('@/lib/client/dexie'); await addPdfDocument({ id: meta.id, type: 'pdf', @@ -59,17 +59,17 @@ export async function putCachedPdf(meta: BaseDocument, data: ArrayBuffer): Promi } export async function evictCachedPdf(id: string): Promise { - const { removePdfDocument } = await import('@/lib/dexie'); + const { removePdfDocument } = await import('@/lib/client/dexie'); await removePdfDocument(id); } export async function getCachedEpub(id: string): Promise { - const { getEpubDocument } = await import('@/lib/dexie'); + const { getEpubDocument } = await import('@/lib/client/dexie'); return (await getEpubDocument(id)) ?? null; } export async function putCachedEpub(meta: BaseDocument, data: ArrayBuffer): Promise { - const { addEpubDocument } = await import('@/lib/dexie'); + const { addEpubDocument } = await import('@/lib/client/dexie'); await addEpubDocument({ id: meta.id, type: 'epub', @@ -81,17 +81,17 @@ export async function putCachedEpub(meta: BaseDocument, data: ArrayBuffer): Prom } export async function evictCachedEpub(id: string): Promise { - const { removeEpubDocument } = await import('@/lib/dexie'); + const { removeEpubDocument } = await import('@/lib/client/dexie'); await removeEpubDocument(id); } export async function getCachedHtml(id: string): Promise { - const { getHtmlDocument } = await import('@/lib/dexie'); + const { getHtmlDocument } = await import('@/lib/client/dexie'); return (await getHtmlDocument(id)) ?? null; } export async function putCachedHtml(meta: BaseDocument, data: string): Promise { - const { addHtmlDocument } = await import('@/lib/dexie'); + const { addHtmlDocument } = await import('@/lib/client/dexie'); await addHtmlDocument({ id: meta.id, type: 'html', @@ -103,12 +103,12 @@ export async function putCachedHtml(meta: BaseDocument, data: string): Promise { - const { removeHtmlDocument } = await import('@/lib/dexie'); + const { removeHtmlDocument } = await import('@/lib/client/dexie'); await removeHtmlDocument(id); } export async function clearDocumentCache(): Promise { - const { clearPdfDocuments, clearEpubDocuments, clearHtmlDocuments } = await import('@/lib/dexie'); + const { clearPdfDocuments, clearEpubDocuments, clearHtmlDocuments } = await import('@/lib/client/dexie'); await Promise.all([clearPdfDocuments(), clearEpubDocuments(), clearHtmlDocuments()]); } @@ -132,7 +132,7 @@ export async function ensureCachedDocument(meta: BaseDocument, options?: { signa meta, { get: async (m) => { - const { getPdfDocument, getEpubDocument, getHtmlDocument } = await import('@/lib/dexie'); + const { getPdfDocument, getEpubDocument, getHtmlDocument } = await import('@/lib/client/dexie'); if (m.type === 'pdf') return (await getPdfDocument(m.id)) ?? null; if (m.type === 'epub') return (await getEpubDocument(m.id)) ?? null; return (await getHtmlDocument(m.id)) ?? null; diff --git a/src/lib/document-preview-cache.ts b/src/lib/client/cache/previews.ts similarity index 98% rename from src/lib/document-preview-cache.ts rename to src/lib/client/cache/previews.ts index ead268d..a5f91a9 100644 --- a/src/lib/document-preview-cache.ts +++ b/src/lib/client/cache/previews.ts @@ -3,8 +3,8 @@ import { getDocumentPreviewCache, putDocumentPreviewCache, removeDocumentPreviewCache, -} from '@/lib/dexie'; -import { documentPreviewFallbackUrl, documentPreviewPresignUrl } from '@/lib/client-documents'; +} from '@/lib/client/dexie'; +import { documentPreviewFallbackUrl, documentPreviewPresignUrl } from '@/lib/client/api/documents'; const inMemoryPreviewUrlCache = new Map(); const inFlightPreviewPrime = new Map>(); diff --git a/src/lib/dexie.ts b/src/lib/client/dexie.ts similarity index 99% rename from src/lib/dexie.ts rename to src/lib/client/dexie.ts index 50c64f4..5025a4c 100644 --- a/src/lib/dexie.ts +++ b/src/lib/client/dexie.ts @@ -8,9 +8,9 @@ import { BaseDocument, DocumentListDocument, } from '@/types/documents'; -import { sha256HexFromBytes, sha256HexFromString } from '@/lib/sha256'; -import { downloadDocumentContent, listDocuments, uploadDocumentSources, type UploadSource } from '@/lib/client-documents'; -import { cacheStoredDocumentFromBytes } from '@/lib/document-cache'; +import { sha256HexFromBytes, sha256HexFromString } from '@/lib/client/sha256'; +import { downloadDocumentContent, listDocuments, uploadDocumentSources, type UploadSource } from '@/lib/client/api/documents'; +import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents'; const DB_NAME = 'openreader-db'; // Managed via Dexie (version bumped from the original manual IndexedDB) diff --git a/src/lib/epub.ts b/src/lib/client/epub.ts similarity index 100% rename from src/lib/epub.ts rename to src/lib/client/epub.ts diff --git a/src/lib/pdfHighlightWorker.ts b/src/lib/client/pdf-highlight-worker.ts similarity index 100% rename from src/lib/pdfHighlightWorker.ts rename to src/lib/client/pdf-highlight-worker.ts diff --git a/src/lib/pdf.ts b/src/lib/client/pdf.ts similarity index 99% rename from src/lib/pdf.ts rename to src/lib/client/pdf.ts index 374149c..6c09d24 100644 --- a/src/lib/pdf.ts +++ b/src/lib/client/pdf.ts @@ -32,7 +32,7 @@ function getHighlightWorker(): Worker | null { try { highlightWorker = new Worker( - new URL('pdfHighlightWorker.ts', import.meta.url), + new URL('pdf-highlight-worker.ts', import.meta.url), { type: 'module' } ); return highlightWorker; diff --git a/src/lib/sha256.ts b/src/lib/client/sha256.ts similarity index 100% rename from src/lib/sha256.ts rename to src/lib/client/sha256.ts diff --git a/src/lib/documentPreview.ts b/src/lib/documentPreview.ts deleted file mode 100644 index d515012..0000000 --- a/src/lib/documentPreview.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { pdfjs } from 'react-pdf'; -import ePub from 'epubjs'; -export { extractRawTextSnippet, extractTextSnippet } from '@/lib/text-snippets'; - -function shouldUseLegacyPdfWorker(): boolean { - if (typeof window === 'undefined') return false; - const ua = window.navigator.userAgent; - const isSafari = /^((?!chrome|android).)*safari/i.test(ua); - if (!isSafari) return false; - const match = ua.match(/Version\/(\d+)/i); - if (!match?.[1]) return true; - const version = Number.parseInt(match[1], 10); - return Number.isFinite(version) ? version < 18 : true; -} - -export function ensurePdfWorker(): void { - if (typeof window === 'undefined') return; - if (pdfjs.GlobalWorkerOptions.workerSrc) return; - - const useLegacy = shouldUseLegacyPdfWorker(); - const workerSrc = useLegacy - ? new URL('pdfjs-dist/legacy/build/pdf.worker.min.mjs', import.meta.url).href - : new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).href; - - pdfjs.GlobalWorkerOptions.workerSrc = workerSrc; - pdfjs.GlobalWorkerOptions.workerPort = null; -} - -async function blobToDataUrl(blob: Blob): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onerror = () => reject(new Error('Failed to read blob')); - reader.onload = () => resolve(String(reader.result)); - reader.readAsDataURL(blob); - }); -} - -async function scaleImageBlobToDataUrl(blob: Blob, targetWidth: number): Promise { - if (typeof window === 'undefined') { - return blobToDataUrl(blob); - } - - if (typeof createImageBitmap !== 'function') { - return blobToDataUrl(blob); - } - - const bitmap = await createImageBitmap(blob); - try { - const scale = targetWidth > 0 ? targetWidth / bitmap.width : 1; - const width = Math.max(1, Math.round(bitmap.width * scale)); - const height = Math.max(1, Math.round(bitmap.height * scale)); - - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext('2d', { alpha: false }); - if (!ctx) { - return blobToDataUrl(blob); - } - - ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, 0, width, height); - ctx.drawImage(bitmap, 0, 0, width, height); - - const dataUrl = canvas.toDataURL('image/jpeg', 0.85); - return dataUrl; - } finally { - bitmap.close(); - } -} - -export async function renderPdfFirstPageToDataUrl( - data: ArrayBuffer, - targetWidth: number, -): Promise { - if (typeof window === 'undefined') { - throw new Error('PDF thumbnail rendering must run in the browser'); - } - - ensurePdfWorker(); - - const loadingTask = pdfjs.getDocument({ data }); - const pdf = await loadingTask.promise; - - try { - const page = await pdf.getPage(1); - const viewport = page.getViewport({ scale: 1 }); - const scale = targetWidth > 0 ? targetWidth / viewport.width : 1; - const scaledViewport = page.getViewport({ scale }); - - const canvas = document.createElement('canvas'); - canvas.width = Math.max(1, Math.floor(scaledViewport.width)); - canvas.height = Math.max(1, Math.floor(scaledViewport.height)); - const ctx = canvas.getContext('2d', { alpha: false }); - if (!ctx) { - throw new Error('Failed to create canvas context'); - } - - ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - const renderTask = page.render({ - canvasContext: ctx, - viewport: scaledViewport, - intent: 'display', - }); - await renderTask.promise; - - return canvas.toDataURL('image/jpeg', 0.82); - } finally { - await pdf.destroy().catch(() => undefined); - await loadingTask.destroy().catch(() => undefined); - } -} - -export async function extractEpubCoverToDataUrl( - data: ArrayBuffer, - targetWidth: number, -): Promise { - if (typeof window === 'undefined') { - return null; - } - - const book = ePub(data); - const opened = book.opened.catch(() => undefined); - try { - const coverObjectUrl = await book.coverUrl(); - if (!coverObjectUrl) return null; - - const res = await fetch(coverObjectUrl); - const blob = await res.blob(); - if (coverObjectUrl.startsWith('blob:')) { - URL.revokeObjectURL(coverObjectUrl); - } - - return await scaleImageBlobToDataUrl(blob, targetWidth); - } catch { - return null; - } finally { - void opened.finally(() => { - try { - book.destroy(); - } catch { - // ignore - } - }); - } -} - -// Note: text snippet helpers live in `src/lib/text-snippets.ts` to stay server-safe. diff --git a/src/lib/server/audiobooks-blobstore.ts b/src/lib/server/audiobooks/blobstore.ts similarity index 99% rename from src/lib/server/audiobooks-blobstore.ts rename to src/lib/server/audiobooks/blobstore.ts index fa07aa1..ef6529d 100644 --- a/src/lib/server/audiobooks-blobstore.ts +++ b/src/lib/server/audiobooks/blobstore.ts @@ -6,7 +6,7 @@ import { ListObjectsV2Command, PutObjectCommand, } from '@aws-sdk/client-s3'; -import { getS3Client, getS3Config } from '@/lib/server/s3'; +import { getS3Client, getS3Config } from '@/lib/server/storage/s3'; const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; const SAFE_BOOK_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; diff --git a/src/lib/server/audiobook.ts b/src/lib/server/audiobooks/chapters.ts similarity index 98% rename from src/lib/server/audiobook.ts rename to src/lib/server/audiobooks/chapters.ts index 6c426df..36fa054 100644 --- a/src/lib/server/audiobook.ts +++ b/src/lib/server/audiobooks/chapters.ts @@ -102,7 +102,7 @@ function parseTitleFromFFMetadata(stdout: string): string | undefined { } export async function ffprobeAudio(filePath: string, signal?: AbortSignal): Promise { - const { getFFmpegPath } = await import('@/lib/server/ffmpeg-bin'); + const { getFFmpegPath } = await import('@/lib/server/audiobooks/ffmpeg-bin'); return new Promise((resolve, reject) => { const ffmpeg = spawn(getFFmpegPath(), [ diff --git a/src/lib/server/ffmpeg-bin.ts b/src/lib/server/audiobooks/ffmpeg-bin.ts similarity index 100% rename from src/lib/server/ffmpeg-bin.ts rename to src/lib/server/audiobooks/ffmpeg-bin.ts diff --git a/src/lib/server/audiobook-prune.ts b/src/lib/server/audiobooks/prune.ts similarity index 100% rename from src/lib/server/audiobook-prune.ts rename to src/lib/server/audiobooks/prune.ts diff --git a/src/lib/server/audiobook-scope.ts b/src/lib/server/audiobooks/user-scope.ts similarity index 100% rename from src/lib/server/audiobook-scope.ts rename to src/lib/server/audiobooks/user-scope.ts diff --git a/src/lib/server/auth.ts b/src/lib/server/auth/auth.ts similarity index 98% rename from src/lib/server/auth.ts rename to src/lib/server/auth/auth.ts index 8b6b429..7425ab1 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth/auth.ts @@ -5,7 +5,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { db } from "@/db"; -import { isAuthEnabled, isAnonymousAuthSessionsEnabled } from "@/lib/server/auth-config"; +import { isAuthEnabled, isAnonymousAuthSessionsEnabled } from "@/lib/server/auth/config"; import * as authSchemaSqlite from "@/db/schema_auth_sqlite"; import * as authSchemaPostgres from "@/db/schema_auth_postgres"; @@ -76,7 +76,7 @@ const createAuth = () => betterAuth({ enabled: true, beforeDelete: async (user) => { try { - const { deleteUserStorageData } = await import('@/lib/server/user-data-cleanup'); + const { deleteUserStorageData } = await import('@/lib/server/user/data-cleanup'); await deleteUserStorageData(user.id, null); } catch (error) { console.error('[auth] Failed to clean up user storage before deletion:', error); @@ -122,8 +122,8 @@ const createAuth = () => betterAuth({ // Lazy-load heavy modules only when account linking actually happens const [{ rateLimiter }, claimData] = await Promise.all([ - import('@/lib/server/rate-limiter'), - import('@/lib/server/claim-data'), + import('@/lib/server/rate-limit/rate-limiter'), + import('@/lib/server/user/claim-data'), ]); // Transfer rate limiting data (TTS char counts) from anonymous user to authenticated user diff --git a/src/lib/server/auth-config.ts b/src/lib/server/auth/config.ts similarity index 100% rename from src/lib/server/auth-config.ts rename to src/lib/server/auth/config.ts diff --git a/src/lib/server/documents-blobstore.ts b/src/lib/server/documents/blobstore.ts similarity index 99% rename from src/lib/server/documents-blobstore.ts rename to src/lib/server/documents/blobstore.ts index 9d0b189..d94dc07 100644 --- a/src/lib/server/documents-blobstore.ts +++ b/src/lib/server/documents/blobstore.ts @@ -7,7 +7,7 @@ import { PutObjectCommand, } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { getS3Client, getS3Config } from '@/lib/server/s3'; +import { getS3Client, getS3Config } from '@/lib/server/storage/s3'; const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i; const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; diff --git a/src/lib/server/document-previews-blobstore.ts b/src/lib/server/documents/previews-blobstore.ts similarity index 97% rename from src/lib/server/document-previews-blobstore.ts rename to src/lib/server/documents/previews-blobstore.ts index 620ee90..e6fc3df 100644 --- a/src/lib/server/document-previews-blobstore.ts +++ b/src/lib/server/documents/previews-blobstore.ts @@ -4,8 +4,8 @@ import { PutObjectCommand, } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { deleteDocumentPrefix, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents-blobstore'; -import { getS3Client, getS3Config } from '@/lib/server/s3'; +import { deleteDocumentPrefix, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore'; +import { getS3Client, getS3Config } from '@/lib/server/storage/s3'; const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; const DEFAULT_NAMESPACE_SEGMENT = '_default'; diff --git a/src/lib/server/document-previews-render.ts b/src/lib/server/documents/previews-render.ts similarity index 100% rename from src/lib/server/document-previews-render.ts rename to src/lib/server/documents/previews-render.ts diff --git a/src/lib/server/document-previews.ts b/src/lib/server/documents/previews.ts similarity index 98% rename from src/lib/server/document-previews.ts rename to src/lib/server/documents/previews.ts index 99a72a0..e73e382 100644 --- a/src/lib/server/document-previews.ts +++ b/src/lib/server/documents/previews.ts @@ -14,9 +14,9 @@ import { headDocumentPreview, isMissingBlobError, putDocumentPreviewBuffer, -} from '@/lib/server/document-previews-blobstore'; -import { getDocumentBlob } from '@/lib/server/documents-blobstore'; -import { renderEpubCoverToJpeg, renderPdfFirstPageToJpeg } from '@/lib/server/document-previews-render'; +} from '@/lib/server/documents/previews-blobstore'; +import { getDocumentBlob } from '@/lib/server/documents/blobstore'; +import { renderEpubCoverToJpeg, renderPdfFirstPageToJpeg } from '@/lib/server/documents/previews-render'; const LEASE_MS = 45_000; const RETRY_AFTER_MS = 1_500; diff --git a/src/lib/text-snippets.ts b/src/lib/server/documents/text-snippets.ts similarity index 100% rename from src/lib/text-snippets.ts rename to src/lib/server/documents/text-snippets.ts diff --git a/src/lib/server/documents-utils.ts b/src/lib/server/documents/utils.ts similarity index 100% rename from src/lib/server/documents-utils.ts rename to src/lib/server/documents/utils.ts diff --git a/src/lib/server/device-id.ts b/src/lib/server/rate-limit/device-id.ts similarity index 100% rename from src/lib/server/device-id.ts rename to src/lib/server/rate-limit/device-id.ts diff --git a/src/lib/server/rate-limiter.ts b/src/lib/server/rate-limit/rate-limiter.ts similarity index 99% rename from src/lib/server/rate-limiter.ts rename to src/lib/server/rate-limit/rate-limiter.ts index af51163..a58050d 100644 --- a/src/lib/server/rate-limiter.ts +++ b/src/lib/server/rate-limit/rate-limiter.ts @@ -1,6 +1,6 @@ import { db } from '@/db'; import { userTtsChars } from '@/db/schema'; -import { isAuthEnabled } from '@/lib/server/auth-config'; +import { isAuthEnabled } from '@/lib/server/auth/config'; import { eq, and, lt, sql } from 'drizzle-orm'; function readPositiveIntEnv(name: string, fallback: number): number { diff --git a/src/lib/server/request-ip.ts b/src/lib/server/rate-limit/request-ip.ts similarity index 100% rename from src/lib/server/request-ip.ts rename to src/lib/server/rate-limit/request-ip.ts diff --git a/src/lib/server/docstore.ts b/src/lib/server/storage/docstore-legacy.ts similarity index 100% rename from src/lib/server/docstore.ts rename to src/lib/server/storage/docstore-legacy.ts diff --git a/src/lib/server/library.ts b/src/lib/server/storage/library-mount.ts similarity index 100% rename from src/lib/server/library.ts rename to src/lib/server/storage/library-mount.ts diff --git a/src/lib/server/s3.ts b/src/lib/server/storage/s3.ts similarity index 100% rename from src/lib/server/s3.ts rename to src/lib/server/storage/s3.ts diff --git a/src/lib/server/test-namespace.ts b/src/lib/server/testing/test-namespace.ts similarity index 93% rename from src/lib/server/test-namespace.ts rename to src/lib/server/testing/test-namespace.ts index e5aa038..eb9278a 100644 --- a/src/lib/server/test-namespace.ts +++ b/src/lib/server/testing/test-namespace.ts @@ -1,5 +1,5 @@ import path from 'path'; -import { UNCLAIMED_USER_ID } from '@/lib/server/docstore'; +import { UNCLAIMED_USER_ID } from '@/lib/server/storage/docstore-legacy'; import { ensureSystemUserExists } from '@/db'; const TEST_NAMESPACE_HEADER = 'x-openreader-test-namespace'; diff --git a/src/lib/server/claim-data.ts b/src/lib/server/user/claim-data.ts similarity index 97% rename from src/lib/server/claim-data.ts rename to src/lib/server/user/claim-data.ts index 3037720..a850c2d 100644 --- a/src/lib/server/claim-data.ts +++ b/src/lib/server/user/claim-data.ts @@ -1,16 +1,16 @@ import { db } from '@/db'; import { documents, audiobooks, audiobookChapters, userPreferences, userDocumentProgress } from '@/db/schema'; import { eq, and, inArray } from 'drizzle-orm'; -import { UNCLAIMED_USER_ID } from './docstore'; +import { UNCLAIMED_USER_ID } from '../storage/docstore-legacy'; import { deleteAudiobookObject, getAudiobookObjectBuffer, listAudiobookObjects, putAudiobookObject, -} from './audiobooks-blobstore'; -import { isS3Configured } from './s3'; +} from '../audiobooks/blobstore'; +import { isS3Configured } from '../storage/s3'; -import { isAuthEnabled } from '@/lib/server/auth-config'; +import { isAuthEnabled } from '@/lib/server/auth/config'; type AudiobookRow = { id: string; diff --git a/src/lib/server/user-data-cleanup.ts b/src/lib/server/user/data-cleanup.ts similarity index 93% rename from src/lib/server/user-data-cleanup.ts rename to src/lib/server/user/data-cleanup.ts index 81d78ae..187fc4a 100644 --- a/src/lib/server/user-data-cleanup.ts +++ b/src/lib/server/user/data-cleanup.ts @@ -7,10 +7,10 @@ import { db } from '@/db'; import { documents, audiobooks } from '@/db/schema'; import { eq } from 'drizzle-orm'; -import { isS3Configured } from '@/lib/server/s3'; -import { deleteDocumentBlob } from '@/lib/server/documents-blobstore'; -import { deleteDocumentPreviewArtifacts } from '@/lib/server/document-previews-blobstore'; -import { audiobookPrefix, deleteAudiobookPrefix } from '@/lib/server/audiobooks-blobstore'; +import { isS3Configured } from '@/lib/server/storage/s3'; +import { deleteDocumentBlob } from '@/lib/server/documents/blobstore'; +import { deleteDocumentPreviewArtifacts } from '@/lib/server/documents/previews-blobstore'; +import { audiobookPrefix, deleteAudiobookPrefix } from '@/lib/server/audiobooks/blobstore'; type DocumentRow = { id: string }; type AudiobookRow = { id: string }; diff --git a/src/lib/server/user-export.ts b/src/lib/server/user/data-export.ts similarity index 99% rename from src/lib/server/user-export.ts rename to src/lib/server/user/data-export.ts index 501aaff..4394cf1 100644 --- a/src/lib/server/user-export.ts +++ b/src/lib/server/user/data-export.ts @@ -1,7 +1,7 @@ import type { Archiver } from 'archiver'; import { Readable } from 'stream'; import type { ReadableStream as NodeReadableStream } from 'stream/web'; -import { decodeChapterFileName } from '@/lib/server/audiobook'; +import { decodeChapterFileName } from '@/lib/server/audiobooks/chapters'; export type ExportBlobBody = | NodeJS.ReadableStream diff --git a/src/lib/server/user-state-scope.ts b/src/lib/server/user/resolve-state-scope.ts similarity index 81% rename from src/lib/server/user-state-scope.ts rename to src/lib/server/user/resolve-state-scope.ts index dc4fe7a..01b6488 100644 --- a/src/lib/server/user-state-scope.ts +++ b/src/lib/server/user/resolve-state-scope.ts @@ -1,7 +1,7 @@ import type { NextRequest } from 'next/server'; -import type { AuthContext } from '@/lib/server/auth'; -import { requireAuthContext } from '@/lib/server/auth'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import type { AuthContext } from '@/lib/server/auth/auth'; +import { requireAuthContext } from '@/lib/server/auth/auth'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; export type ResolvedUserStateScope = { auth: AuthContext; diff --git a/src/lib/kokoro.ts b/src/lib/shared/kokoro.ts similarity index 100% rename from src/lib/kokoro.ts rename to src/lib/shared/kokoro.ts diff --git a/src/lib/nlp.ts b/src/lib/shared/nlp.ts similarity index 100% rename from src/lib/nlp.ts rename to src/lib/shared/nlp.ts diff --git a/tests/global-teardown.ts b/tests/global-teardown.ts index 81b7319..f47f32a 100644 --- a/tests/global-teardown.ts +++ b/tests/global-teardown.ts @@ -2,9 +2,9 @@ import { ListObjectsV2Command } from '@aws-sdk/client-s3'; import { and, eq, inArray, like } from 'drizzle-orm'; import { db } from '../src/db'; import { audiobooks, audiobookChapters, documents } from '../src/db/schema'; -import { deleteDocumentPrefix } from '../src/lib/server/documents-blobstore'; -import { deleteAudiobookPrefix } from '../src/lib/server/audiobooks-blobstore'; -import { getS3Client, getS3Config, isS3Configured } from '../src/lib/server/s3'; +import { deleteDocumentPrefix } from '../src/lib/server/documents/blobstore'; +import { deleteAudiobookPrefix } from '../src/lib/server/audiobooks/blobstore'; +import { getS3Client, getS3Config, isS3Configured } from '../src/lib/server/storage/s3'; function chunk(items: T[], size: number): T[][] { if (items.length === 0) return []; diff --git a/tests/unit/audiobook-scope.spec.ts b/tests/unit/audiobook-scope.spec.ts index 5acdee6..b73ba40 100644 --- a/tests/unit/audiobook-scope.spec.ts +++ b/tests/unit/audiobook-scope.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '../../src/lib/server/audiobook-scope'; +import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '../../src/lib/server/audiobooks/user-scope'; test.describe('audiobook scope selection', () => { test('uses only unclaimed scope when auth is disabled', () => { diff --git a/tests/unit/audiobook.spec.ts b/tests/unit/audiobook.spec.ts index 729de08..53417a2 100644 --- a/tests/unit/audiobook.spec.ts +++ b/tests/unit/audiobook.spec.ts @@ -5,7 +5,7 @@ import { decodeChapterTitleTag, encodeChapterFileName, decodeChapterFileName -} from '../../src/lib/server/audiobook'; +} from '../../src/lib/server/audiobooks/chapters'; test.describe('escapeFFMetadata', () => { test('escapes special characters correctly', () => { diff --git a/tests/unit/audiobooks-blobstore.spec.ts b/tests/unit/audiobooks-blobstore.spec.ts index a64087e..198b2bf 100644 --- a/tests/unit/audiobooks-blobstore.spec.ts +++ b/tests/unit/audiobooks-blobstore.spec.ts @@ -4,7 +4,7 @@ import { audiobookPrefix, isMissingBlobError, isPreconditionFailed, -} from '../../src/lib/server/audiobooks-blobstore'; +} from '../../src/lib/server/audiobooks/blobstore'; function configureS3Env() { process.env.S3_BUCKET = process.env.S3_BUCKET || 'test-bucket'; diff --git a/tests/unit/auth-config.spec.ts b/tests/unit/auth-config.spec.ts index 0cc1706..24fb496 100644 --- a/tests/unit/auth-config.spec.ts +++ b/tests/unit/auth-config.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '../../src/lib/server/auth-config'; +import { isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '../../src/lib/server/auth/config'; const ORIGINAL_BASE_URL = process.env.BASE_URL; const ORIGINAL_AUTH_SECRET = process.env.AUTH_SECRET; diff --git a/tests/unit/docstore.spec.ts b/tests/unit/docstore.spec.ts index 55a57e7..7e9f144 100644 --- a/tests/unit/docstore.spec.ts +++ b/tests/unit/docstore.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { getMigratedDocumentFileName } from '../../src/lib/server/docstore'; +import { getMigratedDocumentFileName } from '../../src/lib/server/storage/docstore-legacy'; test.describe('Docstore Filename Safety', () => { const id = 'a'.repeat(64); // Simulate sha256 hex ID diff --git a/tests/unit/document-cache.spec.ts b/tests/unit/document-cache.spec.ts index c1e7a02..061db9a 100644 --- a/tests/unit/document-cache.spec.ts +++ b/tests/unit/document-cache.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '@playwright/test'; import type { BaseDocument, EPUBDocument, HTMLDocument, PDFDocument } from '../../src/types/documents'; -import { ensureCachedDocumentCore } from '../../src/lib/document-cache'; +import { ensureCachedDocumentCore } from '../../src/lib/client/cache/documents'; test.describe('document-cache-core', () => { test('returns cached PDF without downloading', async () => { diff --git a/tests/unit/document-previews-render.spec.ts b/tests/unit/document-previews-render.spec.ts index 4e815d8..85f6a31 100644 --- a/tests/unit/document-previews-render.spec.ts +++ b/tests/unit/document-previews-render.spec.ts @@ -4,7 +4,7 @@ import { readFile } from 'fs/promises'; import { renderEpubCoverToJpeg, renderPdfFirstPageToJpeg, -} from '../../src/lib/server/document-previews-render'; +} from '../../src/lib/server/documents/previews-render'; test.describe('document-previews-render', () => { test('renders first PDF page to JPEG preview', async () => { diff --git a/tests/unit/nlp.spec.ts b/tests/unit/nlp.spec.ts index cb7164b..4bcd687 100644 --- a/tests/unit/nlp.spec.ts +++ b/tests/unit/nlp.spec.ts @@ -5,7 +5,7 @@ import { splitTextToTtsBlocksEPUB, normalizeTextForTts, MAX_BLOCK_LENGTH -} from '../../src/lib/nlp'; +} from '../../src/lib/shared/nlp'; const PDF_MAX_BLOCK_LENGTH = MAX_BLOCK_LENGTH * 2; // splitTextToTtsBlocks can overflow to reach punctuation diff --git a/tests/unit/sha256.spec.ts b/tests/unit/sha256.spec.ts index 030122a..64796a3 100644 --- a/tests/unit/sha256.spec.ts +++ b/tests/unit/sha256.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test'; import { sha256HexSoftwareFromBytes, sha256HexFromString -} from '../../src/lib/sha256'; +} from '../../src/lib/client/sha256'; test.describe('SHA256 Software Implementation', () => { // Known test vectors diff --git a/tests/unit/transfer-user-documents.spec.ts b/tests/unit/transfer-user-documents.spec.ts index 715bf5f..22681ca 100644 --- a/tests/unit/transfer-user-documents.spec.ts +++ b/tests/unit/transfer-user-documents.spec.ts @@ -3,7 +3,7 @@ import Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; import { eq } from 'drizzle-orm'; import { documents } from '../../src/db/schema_sqlite'; -import { transferUserDocuments } from '../../src/lib/server/claim-data'; +import { transferUserDocuments } from '../../src/lib/server/user/claim-data'; test.describe('transferUserDocuments', () => { test('moves document rows to new user without PK conflicts', async () => {