openreader/tests/global-teardown.ts
Richard R 9999cafec1 feat(tts): add TTS segments API, schema, storage, and manifest endpoints
Introduce TTS segment support with new database tables and migrations for both Postgres and SQLite. Implement segment ensure, audio, and manifest API endpoints, segment storage in S3, and segment authorization logic. Update TTS context and client API to use segment-based synthesis and retrieval. Add server-side helpers for segment ID, settings hash, locator normalization, and audio probing. Extend user data cleanup and teardown logic to remove TTS segment objects. Add unit tests for segment helpers.

BREAKING CHANGE: TTS synthesis now uses segment-based storage and APIs; database migrations required.
2026-05-04 10:18:56 -06:00

109 lines
3.8 KiB
TypeScript

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 { deleteTtsSegmentPrefix } from '../src/lib/server/tts/segments-blobstore';
import { getS3Client, getS3Config, isS3Configured } from '../src/lib/server/storage/s3';
function chunk<T>(items: T[], size: number): T[][] {
if (items.length === 0) return [];
const groups: T[][] = [];
for (let i = 0; i < items.length; i += size) {
groups.push(items.slice(i, i + size));
}
return groups;
}
async function listKeysByPrefix(prefix: string): Promise<string[]> {
const config = getS3Config();
const client = getS3Client();
let continuationToken: string | undefined;
const keys: string[] = [];
do {
const response = await client.send(
new ListObjectsV2Command({
Bucket: config.bucket,
Prefix: prefix,
ContinuationToken: continuationToken,
}),
);
for (const entry of response.Contents ?? []) {
if (entry.Key) keys.push(entry.Key);
}
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
} while (continuationToken);
return keys;
}
function parseAudiobookScopeFromKey(
key: string,
audiobooksNsRootPrefix: string,
): { userId: string; bookId: string } | null {
if (!key.startsWith(audiobooksNsRootPrefix)) return null;
const rel = key.slice(audiobooksNsRootPrefix.length);
const parts = rel.split('/');
// ns/<namespace>/users/<userId>/<bookId>-audiobook/<file>
if (parts.length < 5) return null;
if (parts[1] !== 'users') return null;
const encodedUserId = parts[2];
const dirName = parts[3];
if (!encodedUserId || !dirName.endsWith('-audiobook')) return null;
const bookId = dirName.slice(0, -'-audiobook'.length);
if (!bookId) return null;
let userId: string;
try {
userId = decodeURIComponent(encodedUserId);
} catch {
return null;
}
return { userId, bookId };
}
export default async function globalTeardown(): Promise<void> {
// Always clear namespaced no-auth SQL rows from prior runs.
await db.delete(audiobookChapters).where(like(audiobookChapters.userId, 'unclaimed::%'));
await db.delete(audiobooks).where(like(audiobooks.userId, 'unclaimed::%'));
await db.delete(documents).where(like(documents.userId, 'unclaimed::%'));
if (!isS3Configured()) return;
const config = getS3Config();
const docsNsRootPrefix = `${config.prefix}/documents_v1/ns/`;
const audiobooksNsRootPrefix = `${config.prefix}/audiobooks_v1/ns/`;
const ttsSegmentsNsRootPrefix = `${config.prefix}/tts_segments_v1/ns/`;
// Remove SQL audiobook rows for namespaced objects (covers auth claim flows too).
const audiobookKeys = await listKeysByPrefix(audiobooksNsRootPrefix);
const byUser = new Map<string, Set<string>>();
for (const key of audiobookKeys) {
const scope = parseAudiobookScopeFromKey(key, audiobooksNsRootPrefix);
if (!scope) continue;
let set = byUser.get(scope.userId);
if (!set) {
set = new Set<string>();
byUser.set(scope.userId, set);
}
set.add(scope.bookId);
}
for (const [userId, bookIds] of byUser) {
for (const ids of chunk(Array.from(bookIds), 200)) {
await db
.delete(audiobookChapters)
.where(and(eq(audiobookChapters.userId, userId), inArray(audiobookChapters.bookId, ids)));
await db
.delete(audiobooks)
.where(and(eq(audiobooks.userId, userId), inArray(audiobooks.id, ids)));
}
}
await deleteDocumentPrefix(docsNsRootPrefix);
await deleteAudiobookPrefix(audiobooksNsRootPrefix);
await deleteTtsSegmentPrefix(ttsSegmentsNsRootPrefix);
}