// Package domain holds Tapir's core entities. It depends on nothing outside // the standard library — no providers, no storage, no AI. See docs/data-model.md. package domain import ( "errors" "fmt" "time" ) // ErrVideoNotFound is returned when a video id resolves to no video (deleted, // private, or a typo'd paste). Defined in domain so adapters and the web layer // share one sentinel without coupling to each other. var ErrVideoNotFound = errors.New("video not found") // ErrChannelUnavailable is returned by a VideoSource when a channel's upload // playlist returns HTTP 404 — the channel was deleted or made private. The runner // stores these so the account page can surface them to the user. type ErrChannelUnavailable struct { ChannelID string ChannelTitle string } func (e *ErrChannelUnavailable) Error() string { return fmt.Sprintf("channel %q (%s) unavailable: playlist not found", e.ChannelTitle, e.ChannelID) } // Provider identifies a video platform. type Provider string const ( ProviderYouTube Provider = "youtube" ProviderVimeo Provider = "vimeo" ) // TranscriptSource records how a transcript was obtained (or that there was none). type TranscriptSource string const ( SourceCaptions TranscriptSource = "captions" SourceNone TranscriptSource = "none" // SourceRateLimited records that the caption endpoint returned HTTP 429. // Unlike SourceNone (a permanent absence), this is a transient "retry later": // the IP is rate-limited, not the video caption-less. It carries no text // (HasText is false), so the engine degrades the same as SourceNone, but the // runner persists it distinctly to retry after a backoff window. SourceRateLimited TranscriptSource = "rate_limited" ) // User is the Tapir-side profile. At Stage 0 there is exactly one. type User struct { ID string DisplayName string CreatedAt time.Time } // Subscription is a watched channel on a connected account. type Subscription struct { ID string UserID string ConnectionID string ChannelID string ChannelTitle string Active bool } // Video is a single video seen for a user (per-user, not globally deduped — // see docs/data-model.md and DECISIONS.md "Rejected alternatives"). type Video struct { ID string UserID string SubscriptionID string Provider Provider ProviderVideoID string Title string ChannelTitle string URL string PublishedAt time.Time SeenAt time.Time // DurationSeconds is the video length in seconds, when known (fetched by the // ADR-023 videos.list enrichment at discovery). 0 means unknown — the store // preserves a previously-known value rather than overwriting it with 0, and // the onboarding burst (ADR-028) treats unknown as degrade-open (kept). DurationSeconds int } // Transcript is the text of a video (or a record that none was available). type Transcript struct { VideoID string UserID string Source TranscriptSource Language string Content string // empty when Source == SourceNone } // HasText reports whether the transcript carries usable text. func (t Transcript) HasText() bool { return t.Source != SourceNone && t.Content != "" } // Summary is the produced output for a video. type Summary struct { ID string UserID string VideoID string Summary string Highlights []string Takeaways []string AIProvider string // "local" | "anthropic" | "openai" | "gemini" AIModel string FallbackUsed bool CreatedAt time.Time } // Segment is one timed span of a transcription. Times are seconds from the start // of the audio. type Segment struct { Start float64 `json:"start"` End float64 `json:"end"` Text string `json:"text"` } // Transcription is the result of transcribing user-supplied audio (ADR-033): // the full text, its timed segments, which backend served it, and how long the // audio was. Endpoint is recorded because the backends differ in timestamp // granularity — which one served a transcript is a quality fact. type Transcription struct { Text string Segments []Segment Endpoint string AudioSeconds float64 // Reconciliation is set when two engines transcribed the same audio and a local // model chose between their disagreements (ADR-034). Nil for a single engine. Reconciliation *Reconciliation } // ReconcileStatus says what became of an attempt to merge two transcripts. type ReconcileStatus string const ( // ReconcileDone: both engines produced text and the disagreements were resolved. ReconcileDone ReconcileStatus = "reconciled" // ReconcileSingle: only one engine produced text, so there was nothing to merge. ReconcileSingle ReconcileStatus = "single_engine" // ReconcileSkipped: both engines produced text but the model could not be used; // the base text stands, unreconciled. ReconcileSkipped ReconcileStatus = "skipped" ) // ContestedSpot is a place the engines disagreed that the model judged: it changed it, // kept the base's reading, or could not decide. Every judged spot is stored, not only // the ones a reader should check, because the audio is not retained and the kept ones // are what a later evaluation needs (ADR-035). The page lists only the changed and // uncertain ones. type ContestedSpot struct { Seconds float64 `json:"seconds"` // roughly where in the recording Base string `json:"base"` // what the base engine heard Alt string `json:"alt"` // what the other engine heard Used string `json:"used"` // "base" or "alt": the reading now in the text Unsure bool `json:"unsure"` // the model could not decide; the base was kept Before string `json:"before,omitempty"` // the few words just before the spot, to find it by } // Reconciliation records how a merged transcript came about, so the page can say so // plainly and the user can check the uncertain spots. The counts are exact; Contested // is bounded. type Reconciliation struct { Status ReconcileStatus `json:"status"` Reason string `json:"reason,omitempty"` // why single_engine or skipped Base string `json:"base"` // engine whose text is the base Alt string `json:"alt"` Reconciler string `json:"reconciler"` Disputes int `json:"disputes"` // places the engines disagreed AutoResolved int `json:"auto_resolved"` // trivial: compounds, filler words Adjudicated int `json:"adjudicated"` // decided by the model Changed int `json:"changed"` // the alternative's reading was used Unsure int `json:"unsure"` // base kept, flagged Errors int `json:"errors"` // model calls that failed Contested []ContestedSpot `json:"contested,omitempty"` } // StepState is where one phase of an upload job stands (ADR-035). type StepState string const ( StepPending StepState = "pending" StepRunning StepState = "running" StepDone StepState = "done" StepFailed StepState = "failed" // StepSkipped is a step that never ran because the job finished without it, for // example the second engine's step when the first engine's text was used alone. StepSkipped StepState = "skipped" ) // ProgressStep is one named phase of an upload job. Total is zero for a phase with no // count; only a phase whose work is really countable carries one (ADR-035: no // percentage that does not come from real counts). type ProgressStep struct { Key string `json:"key"` Label string `json:"label"` State StepState `json:"state"` StartedAt time.Time `json:"started_at,omitzero"` FinishedAt time.Time `json:"finished_at,omitzero"` Done int `json:"done,omitempty"` Total int `json:"total,omitempty"` } // UploadStatus is the state of an uploaded-audio transcription job. type UploadStatus string const ( UploadQueued UploadStatus = "queued" UploadTranscribing UploadStatus = "transcribing" UploadDone UploadStatus = "done" UploadFailed UploadStatus = "failed" ) // UploadErrorKind is the typed reason an upload failed. It is deliberately NOT a // provider's error string: the whisper server's "failed to process audio" has had // at least five distinct causes, so nothing may branch on it (ADR-033). type UploadErrorKind string const ( // UploadErrInterrupted: the process restarted mid-job. The audio is not // retained, so the job cannot resume; the user re-uploads. UploadErrInterrupted UploadErrorKind = "interrupted" // UploadErrTranscriptionFailed: every configured transcription endpoint failed. UploadErrTranscriptionFailed UploadErrorKind = "transcription_failed" // UploadErrTooLong: the audio, once converted, is longer than the limit. The limit is // first checked against the file's header, which a hostile or damaged file can lie // about (ADR-035 review). UploadErrTooLong UploadErrorKind = "audio_too_long" // UploadErrBusy: too many recordings were already waiting, so this one was not // queued. The user's to retry later (ADR-035 review). UploadErrBusy UploadErrorKind = "queue_full" // UploadErrIncomplete: the audio, once converted, ends far earlier than the file // claimed: a truncated recording that would otherwise give a partial transcript with // no warning. UploadErrIncomplete UploadErrorKind = "audio_incomplete" ) // UploadedTranscript is a user's PRIVATE transcript of audio they uploaded. It is // never stored in, or merged with, the shared public-caption Transcript (ADR-021). // Content and Segments are empty until Status is UploadDone. type UploadedTranscript struct { ID string UserID string Title string Status UploadStatus Content string Segments []Segment Endpoint string AudioSeconds float64 ErrorKind UploadErrorKind // HadVideo records that the uploaded file carried a video track. Only its audio // was used and the video was discarded; the page says so. It is set at upload // and never cleared (ADR-033). HadVideo bool // Reconciliation is how a merged transcript came about, or nil for a single // engine (ADR-034). Set by Get, not by List. Reconciliation *Reconciliation // Progress is the job's phases, in order. Set by Get, not by List (ADR-035). Progress []ProgressStep // ProgressAt is when a phase last really changed or advanced; AliveAt is the // heartbeat. They differ on purpose: alive says the service is still reporting, // progress says work is moving (ADR-035). Both are zero until the job starts. ProgressAt time.Time AliveAt time.Time CreatedAt time.Time FinishedAt time.Time // zero until the job reaches done or failed }