The free-text 'channel' filter was dead: it exact-matched SummaryRow.Channel,
which is just the provider ('youtube'), because videos never stored their source
channel. Now they do.
- migration 014: videos.channel_title (nullable; existing rows backfill on the
next discovery pass, pasted videos immediately).
- discovery (NewVideos) + paste (VideoByID) populate channel_title; UpsertVideo
persists it, preserving an existing title when an update arrives empty.
- store.DistinctChannels lists a user's channels (RLS-scoped); SummaryRow carries
ChannelTitle via the shared projection.
- Filter: single Channel -> Channels []string, matching on ChannelTitle; the feed
renders a multi-select of DistinctChannels (hidden until channels exist).
- migrate tests: 014 reversibility + fixed the relative-step counts in the 010/011
up/down tests (014 shifted the topology).
TDD throughout: channel persist + distinct, adapter channel wiring, multi-channel
filter match, handler channel filter, migration up/down.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
109 lines
3.2 KiB
Go
109 lines
3.2 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 (
|
|
"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
|
|
}
|
|
|
|
// 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
|
|
}
|