2026-05-22 20:26:11 -04:00
package models
import (
"strings"
"time"
)
2026-08-10 18:14:49 -04:00
const (
mediaBaseTypeAudiobook = "audiobook"
mediaBaseTypePodcast = "podcast"
mediaCodecMJPEG = "mjpeg"
)
2026-05-22 20:26:11 -04:00
// MediaFolder represents a row in the media_folders table.
type MediaFolder struct {
ID int
Paths [] string // from media_folder_paths child table
Type string // movies, series, mixed
Name string
Enabled bool
MetadataLanguage string // ISO 639-1 code (e.g. "en", "ja")
2026-06-10 14:58:54 -04:00
AutoTranslateMetadata bool // AI-translate descriptions when providers lack this language
2026-05-22 20:26:11 -04:00
ChapterThumbnailsEnabled bool
IntroDetectionEnabled bool
2026-07-06 18:52:44 -04:00
// TrailerKinds is the allow-list of remote video kinds (ExtraKind values)
// fetched during metadata refresh for this library. Empty disables remote
// videos entirely.
TrailerKinds [] string
PosterPath string // S3 key for library poster image
LastScannedAt * time . Time // nullable
ScanWarningCode * string
ScanWarningMessage * string
ScanWarningAt * time . Time
AllowEmptyCleanupOnce bool
SortOrder int
2026-05-22 20:26:11 -04:00
}
// MediaFile represents a row in the media_files table.
type MediaFile struct {
ID int
ContentID string // Sonyflake ID (nullable until matched)
EpisodeID string // FK to episodes.content_id (nullable)
2026-07-06 18:52:44 -04:00
ExtraID string // FK to media_extras.content_id (nullable); set only for local extras files, which keep ContentID/EpisodeID empty
2026-05-22 20:26:11 -04:00
SeasonNumber int // parsed from filename (nullable)
EpisodeNumber int // parsed from filename (nullable)
MediaFolderID int
CanonicalRootPath string
ObservedRootPath string
ContentGroupKey string
GroupKeyVersion int
BaseTitle string
BaseYear int
BaseType string
IdentityConfidence string
IdentityJSON [] byte
FilePath string
FileSize int64
FileModifiedAt * time . Time
FileHash string // OSHash (16-char hex)
CodecVideo string // h264, hevc, av1
CodecAudio string // aac, opus, flac
Resolution string // 1080p, 2160p
AudioChannels int
HDR bool
Container string // mkv, mp4
Duration int // seconds
Bitrate int // kbps
VideoTracks [] VideoTrack // JSONB
AudioTracks [] AudioTrack // JSONB
SubtitleTracks [] SubtitleTrack // JSONB
ExternalSubtitles [] ExternalSubtitle // JSONB
Chapters [] MediaChapter // JSONB; nil means not yet probed for chapters
ChapterThumbnailRetryAfter * time . Time
ChapterThumbnailFailureCount int
ChapterThumbnailLastError string
IntroStart * float64
IntroEnd * float64
CreditsStart * float64
CreditsEnd * float64
2026-05-23 23:48:25 -04:00
RecapStart * float64
RecapEnd * float64
PreviewStart * float64
PreviewEnd * float64
2026-05-22 20:26:11 -04:00
MarkersSource * string
MarkersConfidence * float64
IntroMarkersSource * string
IntroMarkersProvider * string
IntroMarkersConfidence * float64
IntroMarkersAlgorithm * string
IntroMarkersDetectedAt * time . Time
CreditsMarkersSource * string
CreditsMarkersProvider * string
CreditsMarkersConfidence * float64
CreditsMarkersAlgorithm * string
CreditsMarkersDetectedAt * time . Time
2026-05-23 23:48:25 -04:00
RecapMarkersSource * string
RecapMarkersProvider * string
RecapMarkersConfidence * float64
RecapMarkersAlgorithm * string
RecapMarkersDetectedAt * time . Time
PreviewMarkersSource * string
PreviewMarkersProvider * string
PreviewMarkersConfidence * float64
PreviewMarkersAlgorithm * string
PreviewMarkersDetectedAt * time . Time
2026-05-22 20:26:11 -04:00
EditionRaw string
EditionKey string
EditionConfidence * float64
EditionSource string
PresentationKind string
PresentationGroupKey string
PresentationPartIndex int
PresentationPartTotal int
MultiEpisodeStart int
MultiEpisodeEnd int
ProbeSource string // arrs, local
ProbeUpdatedAt * time . Time
MatchAttemptedAt * time . Time
MissingSince * time . Time
CreatedAt time . Time
UpdatedAt time . Time
}
// MediaChapter represents a single media chapter derived from embedded file metadata.
type MediaChapter struct {
Index int `json:"index"`
Title string `json:"title"`
StartSeconds float64 `json:"start_seconds"`
EndSeconds float64 `json:"end_seconds"`
Source string `json:"source"`
ThumbnailPath string `json:"thumbnail_path,omitempty"`
ThumbnailThumbhash string `json:"thumbnail_thumbhash,omitempty"`
ThumbnailRetryAfter * time . Time `json:"thumbnail_retry_after,omitempty"`
ThumbnailFailedAt * time . Time `json:"thumbnail_failed_at,omitempty"`
ThumbnailLastError string `json:"thumbnail_last_error,omitempty"`
}
// OverlaySummary is the compact media badge payload shared across API surfaces.
type OverlaySummary struct {
Resolution string `json:"resolution,omitempty"`
HDR string `json:"hdr,omitempty"`
Audio string `json:"audio,omitempty"`
AudioChannels string `json:"audio_channels,omitempty"` // "Stereo", "5.1", "7.1"
VideoCodec string `json:"video_codec,omitempty"` // "H.264", "H.265", "AV1"
Container string `json:"container,omitempty"` // "MKV", "MP4"
AspectRatio string `json:"aspect_ratio,omitempty"` // "16:9", "2.39:1"
ReleaseType string `json:"release_type,omitempty"`
Edition string `json:"edition,omitempty"`
MultiAudio bool `json:"multi_audio,omitempty"` // ≥2 distinct audio languages
MultiSub bool `json:"multi_sub,omitempty"` // ≥1 subtitle track (embedded or external)
}
2026-07-07 20:38:44 +02:00
// PrimaryDVProfile returns the Dolby Vision profile of the first video
// track (0 when none/unprobed).
func ( f * MediaFile ) PrimaryDVProfile () int {
if f == nil || len ( f . VideoTracks ) == 0 {
return 0
}
return f . VideoTracks [ 0 ]. DVProfile
}
2026-08-10 18:14:49 -04:00
// AudioOnlyProbeFacts is the compact probe shape needed to distinguish known
// audio media from incomplete video probes and legacy attached cover art.
type AudioOnlyProbeFacts struct {
BaseType string
CodecVideo string
CodecAudio string
HasVideoTracks bool
HasAudioTracks bool
HasNonImageVideoTracks bool
}
func isImageVideoCodec ( codec string ) bool {
switch strings . ToLower ( strings . TrimSpace ( codec )) {
case mediaCodecMJPEG , "jpeg" , "png" , "webp" , "gif" , "bmp" :
return true
default :
return false
}
}
// HasLegacyAttachedPictureVideo reports the stale shape in which every video
// track on known audio media is embedded cover art.
func ( f AudioOnlyProbeFacts ) HasLegacyAttachedPictureVideo () bool {
knownAudioType := f . BaseType == mediaBaseTypeAudiobook || f . BaseType == mediaBaseTypePodcast
hasAudio := f . HasAudioTracks || strings . TrimSpace ( f . CodecAudio ) != ""
if ! knownAudioType || ! hasAudio {
return false
}
if ! f . HasVideoTracks {
return isImageVideoCodec ( f . CodecVideo )
}
if f . HasNonImageVideoTracks {
return false
}
return strings . TrimSpace ( f . CodecVideo ) == "" || isImageVideoCodec ( f . CodecVideo )
}
// IsAudioOnly reports whether these facts contain positive evidence for known
// audio media with no playable video stream.
func ( f AudioOnlyProbeFacts ) IsAudioOnly () bool {
knownAudioType := f . BaseType == mediaBaseTypeAudiobook || f . BaseType == mediaBaseTypePodcast
hasAudio := f . HasAudioTracks || strings . TrimSpace ( f . CodecAudio ) != ""
return ( knownAudioType && hasAudio && ! f . HasVideoTracks && strings . TrimSpace ( f . CodecVideo ) == "" ) || f . HasLegacyAttachedPictureVideo ()
}
// AudioOnlyProbeFacts returns the compact stream evidence for this media file.
func ( f * MediaFile ) AudioOnlyProbeFacts () AudioOnlyProbeFacts {
if f == nil {
return AudioOnlyProbeFacts {}
}
facts := AudioOnlyProbeFacts {
BaseType : f . BaseType ,
CodecVideo : f . CodecVideo ,
CodecAudio : f . CodecAudio ,
HasVideoTracks : len ( f . VideoTracks ) > 0 ,
HasAudioTracks : len ( f . AudioTracks ) > 0 ,
}
for _ , track := range f . VideoTracks {
if ! isImageVideoCodec ( track . Codec ) {
facts . HasNonImageVideoTracks = true
break
}
}
return facts
}
// HasLegacyAttachedPictureVideo reports the stale catalog shape produced when
// older probes recorded embedded cover art as a video track. BaseType and
// audio evidence keep genuine MJPEG video from being normalized away.
func ( f * MediaFile ) HasLegacyAttachedPictureVideo () bool {
return f . AudioOnlyProbeFacts (). HasLegacyAttachedPictureVideo ()
}
// IsAudioOnly reports whether a probed file carries no playable video stream —
// audiobooks and podcasts, as opposed to a video file whose probe is incomplete.
// It also normalizes legacy audiobook/podcast cover-art rows until a repair
// probe removes their stale image-only video metadata.
func ( f * MediaFile ) IsAudioOnly () bool {
return f . AudioOnlyProbeFacts (). IsAudioOnly ()
}
2026-07-14 11:51:27 -04:00
// NormalizeVideoBitDepth returns an explicit probe value when available and
// otherwise derives the bit depth from stable ffprobe fields. FFprobe commonly
// omits bits_per_raw_sample for HEVC even though the pixel format and profile
// are conclusive (for example yuv420p10le / Main 10).
func NormalizeVideoBitDepth ( explicit int , pixelFormat , profile string ) int {
if explicit > 0 {
return explicit
}
pixelFormat = strings . ToLower ( strings . TrimSpace ( pixelFormat ))
for _ , candidate := range [] struct {
markers [] string
depth int
}{
{ markers : [] string { "p016" , "p16" , "gray16" , "16le" , "16be" }, depth : 16 },
{ markers : [] string { "p014" , "p14" , "gray14" , "14le" , "14be" }, depth : 14 },
{ markers : [] string { "p012" , "p12" , "gray12" , "12le" , "12be" }, depth : 12 },
{ markers : [] string { "p010" , "p10" , "gray10" , "10le" , "10be" }, depth : 10 },
{ markers : [] string { "p009" , "p9" , "gray9" , "9le" , "9be" }, depth : 9 },
} {
for _ , marker := range candidate . markers {
if strings . Contains ( pixelFormat , marker ) {
return candidate . depth
}
}
}
profile = strings . ToLower ( strings . TrimSpace ( profile ))
for _ , marker := range [] string { "main 12" , "main12" , "12-bit" , "12 bit" } {
if strings . Contains ( profile , marker ) {
return 12
}
}
for _ , marker := range [] string { "main 10" , "main10" , "10-bit" , "10 bit" } {
if strings . Contains ( profile , marker ) {
return 10
}
}
switch pixelFormat {
case "yuv420p" , "yuv422p" , "yuv444p" , "yuvj420p" , "yuvj422p" , "yuvj444p" , "nv12" , "nv21" , "rgb24" , "bgr24" :
return 8
}
return 0
}
2026-05-22 20:26:11 -04:00
// VideoTrack represents a probed video stream stored as JSONB.
type VideoTrack struct {
2026-07-14 11:51:27 -04:00
Title string `json:"title,omitempty"`
Codec string `json:"codec,omitempty"`
DolbyVision string `json:"dolby_vision,omitempty"`
DVProfile int `json:"dv_profile,omitempty"`
DVBLCompatID int `json:"dv_bl_compat_id,omitempty"`
DVELPresent bool `json:"dv_el_present,omitempty"`
DVEnhancementLayer string `json:"dv_enhancement_layer,omitempty"` // none, mel, fel, unknown
HDR10Plus bool `json:"hdr10_plus,omitempty"`
Profile string `json:"profile,omitempty"`
Level int `json:"level,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
AspectRatio string `json:"aspect_ratio,omitempty"`
Interlaced bool `json:"interlaced"`
FrameRate string `json:"frame_rate,omitempty"`
Bitrate int `json:"bitrate,omitempty"`
VideoRange string `json:"video_range,omitempty"`
VideoRangeType string `json:"video_range_type,omitempty"`
2026-07-21 09:50:37 -04:00
ColorRange string `json:"color_range,omitempty"`
2026-07-14 11:51:27 -04:00
ColorPrimaries string `json:"color_primaries,omitempty"`
ColorSpace string `json:"color_space,omitempty"`
ColorTransfer string `json:"color_transfer,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
PixelFormat string `json:"pixel_format,omitempty"`
ReferenceFrames int `json:"reference_frames,omitempty"`
2026-07-17 16:26:08 +00:00
// MultiplePPS records whether an H.264 stream redefines the same
// pic_parameter_set_id in-band with more than one distinct content. Such
// streams cannot be safely stream-copied into an avc1/fMP4 HLS segment:
// the avcC declares a single parameter set, so strict decoders
// (VideoToolbox on Safari/Chrome-macOS) desync on the mid-GOP switches.
//
// This is a runtime-only field: it is computed at playback start by a
// bitstream scan and held in memory, never serialized to the database
// (`json:"-"`). nil means "not analyzed in this process yet".
MultiplePPS * bool `json:"-"`
2026-07-29 00:38:30 +00:00
// VideoCopyUnsafe is set when conflicting PPS data is detected or when the
// safety scan cannot establish that video stream-copy is safe. It is
// runtime-only so transient scan failures are retried on a later request.
VideoCopyUnsafe bool `json:"-"`
2026-05-22 20:26:11 -04:00
}
// AudioTrack represents a probed audio stream stored as JSONB.
type AudioTrack struct {
Title string `json:"title,omitempty"`
EmbeddedTitle string `json:"embedded_title,omitempty"`
Language string `json:"language,omitempty"`
Codec string `json:"codec,omitempty"`
2026-07-08 11:38:14 -04:00
Profile string `json:"profile,omitempty"`
2026-05-22 20:26:11 -04:00
Layout string `json:"layout,omitempty"`
Channels int `json:"channels,omitempty"`
Bitrate int `json:"bitrate,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
Default bool `json:"default"`
}
// SubtitleTrack represents an embedded subtitle track stored as JSONB.
type SubtitleTrack struct {
Index int `json:"index"`
Language string `json:"language"`
Codec string `json:"codec"`
Title string `json:"title,omitempty"`
EmbeddedTitle string `json:"embedded_title,omitempty"`
Resolution string `json:"resolution,omitempty"`
Forced bool `json:"forced"`
Default bool `json:"default"`
HearingImpaired bool `json:"hearing_impaired"`
External bool `json:"external"`
FileName string `json:"file_name,omitempty"`
}
// ExternalSubtitle represents a sidecar subtitle file stored as JSONB.
type ExternalSubtitle struct {
Path string `json:"path"`
Language string `json:"language"`
Format string `json:"format"` // srt, vtt, ass, ssa, sub
Title string `json:"title,omitempty"`
EmbeddedTitle string `json:"embedded_title,omitempty"`
Resolution string `json:"resolution,omitempty"`
Forced bool `json:"forced"`
Default bool `json:"default"`
HearingImpaired bool `json:"hearing_impaired"`
}
// PersonKind identifies the role type a person has on a media item.
type PersonKind int
const (
PersonKindActor PersonKind = 1
PersonKindDirector PersonKind = 2
PersonKindWriter PersonKind = 3
PersonKindProducer PersonKind = 4
PersonKindGuestStar PersonKind = 5
PersonKindComposer PersonKind = 6
2026-06-07 15:57:05 -04:00
PersonKindAuthor PersonKind = 7
PersonKindNarrator PersonKind = 8
2026-05-22 20:26:11 -04:00
)
// String returns the Jellyfin-compatible type string for this PersonKind.
func ( k PersonKind ) String () string {
switch k {
case PersonKindActor :
return "Actor"
case PersonKindDirector :
return "Director"
case PersonKindWriter :
return "Writer"
case PersonKindProducer :
return "Producer"
case PersonKindGuestStar :
return "GuestStar"
case PersonKindComposer :
return "Composer"
2026-06-07 15:57:05 -04:00
case PersonKindAuthor :
return "Author"
case PersonKindNarrator :
return "Narrator"
2026-05-22 20:26:11 -04:00
default :
return "Unknown"
}
}
// PersonKindFromJob maps a crew job title to a PersonKind.
func PersonKindFromJob ( job string ) PersonKind {
switch strings . ToLower ( strings . TrimSpace ( job )) {
case "director" :
return PersonKindDirector
case "writer" , "screenplay" , "story" , "novel" :
return PersonKindWriter
case "composer" , "original music composer" , "music" :
return PersonKindComposer
default :
return PersonKindProducer
}
}
// Person represents a deduplicated person entity.
type Person struct {
2026-06-18 10:07:58 -04:00
ID int64
Name string
SortName string
Bio string
BirthDate * time . Time
DeathDate * time . Time
Birthplace string
Homepage string
PhotoPath string
PhotoSourcePath string
PhotoThumbhash string
TmdbID string
ImdbID string
TvdbID string
PlexGUID string
CreatedAt time . Time
UpdatedAt time . Time
2026-05-22 20:26:11 -04:00
}
// ItemPerson represents a person's credit on a specific media item.
type ItemPerson struct {
Person
Kind PersonKind
Character string
SortOrder int
}
2026-06-07 15:57:05 -04:00
// AudiobookSeriesMembership captures a book's membership in an audiobook
// series and its optional sequence number within that series.
type AudiobookSeriesMembership struct {
Name string
Index * float64
}
2026-05-22 20:26:11 -04:00
// MediaItem represents a row in the media_items table.
type MediaItem struct {
ContentID string // Sonyflake ID (PK)
Type string // movie, series
Title string
SortTitle string
DefaultMetadataLanguage string
OriginalTitle string
Year int
Genres [] string
ContentRating string // PG-13, TV-MA
Runtime int // minutes
Overview string
Tagline string
RatingIMDB * float64
RatingTMDB * float64
RatingRTCritic * int
RatingRTAudience * int
ImdbID string
TmdbID string
TvdbID string
PosterPath string // S3 path
2026-06-12 09:32:53 -04:00
PosterSourcePath string // provider-origin path kept when caching rewrites PosterPath; feeds outbound embeds
2026-05-22 20:26:11 -04:00
PosterThumbhash string
BackdropPath string
2026-06-18 10:07:58 -04:00
BackdropSourcePath string
2026-05-22 20:26:11 -04:00
BackdropThumbhash string
LogoPath string
2026-06-18 10:07:58 -04:00
LogoSourcePath string
2026-05-22 20:26:11 -04:00
MetadataS3Path string
MetadataEtag string
SeasonCount * int // series only
2026-06-16 20:13:10 -04:00
MangaChapterCount * int // manga series only: loose manga_chapters rows without a volume token
MangaVolumeCount * int // manga series only: distinct non-empty volume tokens in manga_chapters
2026-05-22 20:26:11 -04:00
Studios [] string
Networks [] string
Countries [] string
Keywords [] string
OriginalLanguage string
ReleaseDate * string // ISO date, nullable
FirstAirDate * string // ISO date (series only), nullable
LastAirDate * string // ISO date (series only), nullable
AirTime * string // Series broadcast time (e.g. "20:00"), nullable
2026-05-27 21:32:18 -04:00
AirTimezone * string // Series broadcast timezone (IANA name, e.g. "America/New_York"), nullable
2026-07-07 17:16:38 -04:00
ShowStatus string // Series lifecycle: "returning", "ended", "cancelled", "in_production", "upcoming", or "" if unknown (series; manga uses its own domain, e.g. "Ongoing")
2026-05-22 20:26:11 -04:00
People [] ItemPerson
2026-06-07 15:57:05 -04:00
AudiobookSeries [] AudiobookSeriesMembership
2026-05-22 20:26:11 -04:00
MatchedAt * time . Time
EpisodeMetadataIncomplete bool
EpisodeMetadataLastCheckedAt * time . Time
LastRefreshed * time . Time `json:"last_refreshed,omitempty"`
RefreshFailures int `json:"refresh_failures"`
LockedFields [] int `json:"locked_fields"`
Status string `json:"status"` // pending, matched, unmatched
CreatedAt time . Time
UpdatedAt time . Time
AddedAt * time . Time // populated by browse queries (MIN(mil.first_seen_at))
}
2026-07-24 18:02:52 +02:00
// MediaItemAlias is a provider-confirmed searchable title for a media item.
type MediaItemAlias struct {
ContentID string
Title string
Language string
Kind string
Provider string
}
2026-05-22 20:26:11 -04:00
// Season represents a row in the seasons table.
type Season struct {
ContentID string
SeriesID string
SeasonNumber int
Title string
DefaultMetadataLanguage string
Overview string
AirDate * time . Time
PosterPath string
2026-06-18 10:07:58 -04:00
PosterSourcePath string
2026-05-22 20:26:11 -04:00
PosterThumbhash string
MetadataS3Path string
MetadataEtag string
MetadataSource string
CreatedAt time . Time
UpdatedAt time . Time
}
// Episode represents a row in the episodes table.
type Episode struct {
ContentID string // episode Sonyflake ID (PK)
SeriesID string
SeasonID string // FK to seasons.content_id
SeasonNumber int
EpisodeNumber int
Title string
DefaultMetadataLanguage string
Overview string
AirDate * time . Time
Runtime int // minutes
RatingIMDB * float64
RatingTMDB * float64
ImdbID string
TmdbID string
TvdbID string
StillPath string
2026-06-18 10:07:58 -04:00
StillSourcePath string
2026-05-22 20:26:11 -04:00
StillThumbhash string
MetadataS3Path string
MetadataEtag string
MetadataSource string
CreatedAt time . Time
UpdatedAt time . Time
}
// MediaItemRoot represents a row in the media_item_roots table.
// It records which content_id owns a given canonical root path within a library folder.
type MediaItemRoot struct {
MediaFolderID int
CanonicalRootPath string
ContentID string
FirstSeenAt time . Time
LastSeenAt time . Time
}
// MediaItemGroup represents a row in the media_item_groups table.
type MediaItemGroup struct {
MediaFolderID int
GroupKeyVersion int
ContentGroupKey string
ContentID string
FirstSeenAt time . Time
LastSeenAt time . Time
}
// MediaItemLibrary represents a row in the media_item_libraries junction table.
type MediaItemLibrary struct {
ContentID string
MediaFolderID int
FirstSeenAt time . Time
}
// EpisodeLibrary represents a row in the episode_libraries junction table.
type EpisodeLibrary struct {
EpisodeID string
MediaFolderID int
FirstSeenAt time . Time
}
2026-06-10 14:58:54 -04:00
// Localization field provenance values. Precedence when writing:
// manual beats provider beats ai — a provider refresh may overwrite an AI
// translation but never a manual edit, and AI never overwrites either
// (except provider, when the admin explicitly forces a re-translation).
const (
LocalizationSourceProvider = "provider"
LocalizationSourceAI = "ai"
LocalizationSourceManual = "manual"
)
2026-05-22 20:26:11 -04:00
type MediaItemLocalization struct {
2026-06-18 10:07:58 -04:00
ContentID string
Language string
Title string
SortTitle string
Overview string
Tagline string
PosterPath string
PosterSourcePath string
PosterThumbhash string
BackdropPath string
BackdropSourcePath string
BackdropThumbhash string
LogoPath string
LogoSourcePath string
OverviewSource string // provider | ai | manual
TaglineSource string // provider | ai | manual
CreatedAt time . Time
UpdatedAt time . Time
2026-05-22 20:26:11 -04:00
}
type SeasonLocalization struct {
2026-06-18 10:07:58 -04:00
SeasonContentID string
Language string
Title string
Overview string
PosterPath string
PosterSourcePath string
PosterThumbhash string
OverviewSource string // provider | ai | manual
CreatedAt time . Time
UpdatedAt time . Time
2026-05-22 20:26:11 -04:00
}
type EpisodeLocalization struct {
EpisodeContentID string
Language string
Title string
Overview string
2026-06-10 14:58:54 -04:00
OverviewSource string // provider | ai | manual
2026-05-22 20:26:11 -04:00
CreatedAt time . Time
UpdatedAt time . Time
}