fix(server): type the email module and fix narration audio serving

Three defects, found while planning the Android client:

- The email module's providers, preferences, templates, recipient
  groups, tests and send requests had no types in packages/types, so
  every client mirrored them from server code. They are typed now, from
  the JSON the routes return and accept, and the services and DTOs
  declare them. No response changes.
- Narration audio paths were decoded twice: the router decodes the
  wildcard, and the route decoded it again, so a name holding a literal
  % resolved to the wrong entry or none. A path is now decoded once and
  matched to the playlist's hrefs exactly, with an exact entry name
  preferred. Each request also read the whole audio entry into memory;
  it is now streamed, read from its offset when stored and inflated and
  skipped when deflated, with the same headers and Range handling, and
  an open range at or past the end is a 416. Both narration audio
  routes skip response compression, which dropped Content-Length.
- A playlist's section starts left out any clip of unknown length.
  They now use the media:duration each overlay declares when a clip's
  own length is unknown. A seq that interleaves par and seq children
  was reordered by the XML parser's grouping; SMIL is now read in
  document order.
This commit is contained in:
neon
2026-10-15 05:43:23 +00:00
parent d2aacfae04
commit 3a0c61ffbc
31 changed files with 1034 additions and 137 deletions
+192
View File
@@ -1,3 +1,4 @@
import type { BookSelectionPayload } from "./book-selection";
import type { DeliveryArtifactMetadata, ReadAloudDeliveryPreference } from "./delivery";
export interface EmailRecipient {
@@ -76,3 +77,194 @@ export interface EmailSendLogSummary {
failed: number;
pending: number;
}
/** Every provider route answers with the stored row minus its password. */
export interface EmailProvider {
id: number;
/** Null for a provider no account owns. */
userId: number | null;
name: string;
host: string;
port: number;
username: string | null;
fromName: string | null;
fromAddress: string | null;
auth: boolean;
ssl: boolean;
startTls: boolean;
isDefault: boolean;
isShared: boolean;
isSystemProvider: boolean;
tlsRejectUnauthorized: boolean;
/** Set by the last connection test; absent from servers before 3.4.0. */
lastTestedAt?: string | null;
lastTestOk?: boolean | null;
lastTestError?: string | null;
createdAt: string;
updatedAt: string;
hasPassword: boolean;
}
/** POST /email/providers */
export interface EmailProviderCreateRequest {
name: string;
host: string;
port: number;
username?: string;
password?: string;
fromName?: string;
fromAddress?: string;
auth: boolean;
ssl: boolean;
startTls: boolean;
tlsRejectUnauthorized: boolean;
}
/** PUT /email/providers/:id. An empty password removes the stored one. */
export type EmailProviderUpdateRequest = Partial<EmailProviderCreateRequest>;
/** POST /email/providers/:id/test and POST /email/recipients/:id/test. */
export interface EmailTestResult {
success: boolean;
error?: string;
}
/**
* GET and PUT /email/preferences: the defaults sending uses, including any still carried by a
* row's legacy isDefault flag. An account that never saved preferences gets only userId and the
* three defaults.
*/
export interface EmailPreferences {
id?: number;
userId: number;
defaultProviderId: number | null;
defaultRecipientId: number | null;
defaultTemplateId: number | null;
createdAt?: string;
updatedAt?: string;
}
/** PUT /email/preferences. An omitted default is left alone; null clears it. */
export interface EmailPreferencesUpdateRequest {
defaultProviderId?: number | null;
defaultRecipientId?: number | null;
defaultTemplateId?: number | null;
}
export interface EmailTemplate {
id: number;
/** Null for a built-in template. */
userId: number | null;
name: string;
subject: string;
bodyText: string;
isDefault: boolean;
isSystem: boolean;
createdAt: string;
updatedAt: string;
}
/** POST /email/templates */
export interface EmailTemplateCreateRequest {
name: string;
subject: string;
bodyText: string;
}
/** PUT /email/templates/:id */
export type EmailTemplateUpdateRequest = Partial<EmailTemplateCreateRequest>;
/** POST /email/templates/:id/preview */
export interface EmailTemplatePreviewRequest {
bookId: number;
}
/** POST /email/templates/preview: text from the editor, before it is saved. */
export interface EmailTemplateTextPreviewRequest {
subject: string;
bodyText: string;
bookId: number;
}
/** POST /email/templates/:id/preview */
export interface EmailTemplatePreview {
subject: string;
bodyText: string;
}
/** The file a send of the previewed book would attach, named as the email names it. */
export interface EmailTemplatePreviewAttachment {
fileName: string;
format: string | null;
sizeBytes: number | null;
}
/** POST /email/templates/preview */
export interface EmailTemplateTextPreview extends EmailTemplatePreview {
/** Null when the book has no file a send could attach; absent from servers before 3.4.0. */
attachment?: EmailTemplatePreviewAttachment | null;
}
/** PUT /email/recipient-groups/:id answers this, without the members. */
export interface EmailRecipientGroupSummary {
id: number;
userId: number;
name: string;
defaultTemplateId: number | null;
createdAt: string;
updatedAt: string;
}
/** Every other recipient group route that answers with a group. */
export interface EmailRecipientGroup extends EmailRecipientGroupSummary {
members: EmailRecipient[];
}
/** POST /email/recipient-groups */
export interface EmailRecipientGroupCreateRequest {
name: string;
defaultTemplateId?: number;
}
/** PUT /email/recipient-groups/:id */
export interface EmailRecipientGroupUpdateRequest {
name?: string;
defaultTemplateId?: number | null;
}
/** POST /email/recipient-groups/:id/members */
export interface EmailRecipientGroupMemberRequest {
recipientId: number;
}
/** POST /email/recipients/:id/test, written in the sender's interface language. */
export interface EmailRecipientTestRequest {
subject: string;
text: string;
}
/** Who and how to send, beside the book selection of POST /email/send. */
export interface EmailSendOptions {
recipientIds?: number[];
groupIds?: number[];
fileId?: number;
providerId?: number;
templateId?: number;
}
/** POST /email/send. It needs at least one recipient, given directly or through a group. */
export type EmailSendRequest = BookSelectionPayload & EmailSendOptions;
/** POST /email/send, POST /email/send/quick/:bookId and POST /email/log/:id/resend. */
export interface EmailSendAccepted {
queued: number;
}
/** The query of GET /email/log; GET /email/admin/log reads only page and size. */
export interface EmailSendLogQuery {
page?: number;
size?: number;
bookId?: number;
status?: EmailSendLogEntry["status"];
recipientId?: number;
}