Files
tapir/internal/domain/domain.go
T
mathias f1e9739900
CI / Lint / Test / Vet (push) Successful in 26s
CI / Build & Import (push) Successful in 11s
feat(store,runner,web): channel unavailability notice (migration 013)
YouTube channels that 404 on playlist discovery (deleted/private) are now:
1. Wrapped in domain.ErrChannelUnavailable by the YouTube adapter (instead of
   a generic error), so the runner can identify them without string-matching.
2. Stored per-user in channel_errors (migration 013, RLS-guarded) via runner's
   new UpsertChannelError path — removed from the generic Errors counter,
   counted separately as ChannelUnavailable.
3. Shown on the account page under "Unavailable channels" with name, chip-warn
   badge, and first-seen date, so users know why some subscribed channels
   produce no videos.
2026-06-06 10:09:52 +02:00

102 lines
2.9 KiB
Go

// 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 (
"fmt"
"time"
)
// 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
URL string
PublishedAt time.Time
SeenAt time.Time
}
// 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
}