diff --git a/internal/model/api_config_legacy.go b/internal/model/api_config_legacy.go new file mode 100644 index 0000000..ae0eec7 --- /dev/null +++ b/internal/model/api_config_legacy.go @@ -0,0 +1,23 @@ +package model + +// APIConfig stores third-party data-source configuration. The api_key +// column is encrypted with AES-GCM (see internal/service/crypto.go) so an +// SQLite leak does not expose third-party credentials. +// +// Provider values mirror the original Python project: +// +// tmdb — themoviedb.org +// bangumi — bgm.tv +// thetvdb — thetvdb.com +// fanart — fanart.tv +// douban — douban.com (cookie) +// openai — OpenAI / DeepSeek / Qwen / Ollama (compatible) +type APIConfig struct { + Base + Provider string `gorm:"uniqueIndex;size:32;not null" json:"provider"` + APIKey string `gorm:"type:text" json:"-"` // ciphertext (never serialised) + BaseURL string `gorm:"size:512" json:"base_url,omitempty"` + Extra string `gorm:"type:text" json:"extra,omitempty"` // free-form JSON + Enabled bool `gorm:"default:true" json:"enabled"` + Description string `gorm:"size:255" json:"description,omitempty"` +} diff --git a/internal/model/download_subscription.go b/internal/model/download_subscription.go new file mode 100644 index 0000000..73f8ed1 --- /dev/null +++ b/internal/model/download_subscription.go @@ -0,0 +1,73 @@ +package model + +import "time" + +// DownloadTask 是待处理(或已完成)的 torrent / HTTP 下载。 +type DownloadTask struct { + Base + UserID string `gorm:"index;size:36" json:"user_id"` + SubscriptionID string `gorm:"index;size:36" json:"subscription_id,omitempty"` + Source string `gorm:"size:32;not null" json:"source"` // qbittorrent / transmission / http + URL string `gorm:"size:2048;not null" json:"-"` + Title string `gorm:"size:512" json:"title,omitempty"` + PosterURL string `gorm:"size:2048" json:"poster_url,omitempty"` + BackdropURL string `gorm:"size:2048" json:"backdrop_url,omitempty"` + Overview string `gorm:"type:text" json:"overview,omitempty"` + SavePath string `gorm:"size:1024" json:"save_path"` + MediaType string `gorm:"size:16" json:"media_type,omitempty"` + MediaCategory string `gorm:"size:128" json:"media_category,omitempty"` + // 媒体展示元数据(用于 Telegram 富通知模板等):原始片名/语言/年份/评分/类型。 + OriginalName string `gorm:"size:512" json:"original_name,omitempty"` + OriginalLanguage string `gorm:"size:32" json:"original_language,omitempty"` + Year int `json:"year,omitempty"` + Rating float32 `json:"rating,omitempty"` + Genres string `gorm:"size:255" json:"genres,omitempty"` // comma separated + Status string `gorm:"size:32;default:queued" json:"status"` + Progress float32 `json:"progress"` + + // AllowExistingLibrary is true for subscription wash/upgrade tasks that are + // allowed to replace an existing library item after download completion. + AllowExistingLibrary bool `gorm:"default:false" json:"allow_existing_library,omitempty"` +} + +// Subscription 是自动化规则,轮询 RSS 源并将匹配种子排队到配置的下载客户端。 +type Subscription struct { + Base + UserID string `gorm:"index;size:36" json:"user_id"` + Name string `gorm:"size:128;not null" json:"name"` + FeedURL string `gorm:"size:2048;not null" json:"feed_url"` + Filter string `gorm:"size:512" json:"filter"` + MediaType string `gorm:"size:16" json:"media_type,omitempty"` + MediaCategory string `gorm:"size:128" json:"media_category,omitempty"` + SavePath string `gorm:"size:1024" json:"save_path,omitempty"` + SearchMode string `gorm:"size:16;default:keyword" json:"search_mode,omitempty"` // keyword / imdb + IMDBID string `gorm:"size:32" json:"imdb_id,omitempty"` + Source string `gorm:"size:32" json:"source,omitempty"` + PosterURL string `gorm:"size:2048" json:"poster_url,omitempty"` + BackdropURL string `gorm:"size:2048" json:"backdrop_url,omitempty"` + Overview string `gorm:"type:text" json:"overview,omitempty"` + // 媒体展示元数据(用于 Telegram 富通知模板等):原始片名/语言/年份/评分/类型。 + OriginalName string `gorm:"size:512" json:"original_name,omitempty"` + OriginalLanguage string `gorm:"size:32" json:"original_language,omitempty"` + Year int `json:"year,omitempty"` + Rating float32 `json:"rating,omitempty"` + Genres string `gorm:"size:255" json:"genres,omitempty"` // comma separated + Resolution string `gorm:"size:32" json:"resolution,omitempty"` // 2160p / 1080p / 720p / best + Quality string `gorm:"size:64" json:"quality,omitempty"` // remux / bluray / web-dl / hdtv + Effects string `gorm:"size:128" json:"effects,omitempty"` // hdr,dolby-vision,atmos + ReleaseGroups string `gorm:"size:255" json:"release_groups,omitempty"` // comma separated + ExcludeWords string `gorm:"size:255" json:"exclude_words,omitempty"` // comma separated + WashEnabled bool `gorm:"default:false" json:"wash_enabled"` + WashPriority string `gorm:"size:32" json:"wash_priority,omitempty"` // balanced / resolution / quality / effects / seeders + TotalEpisodes int `gorm:"default:0" json:"total_episodes,omitempty"` + Priority int `gorm:"default:50" json:"priority,omitempty"` // lower is earlier when schedulers sort later + Enabled bool `gorm:"default:true" json:"enabled"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + ArchivedAt *time.Time `gorm:"index" json:"archived_at,omitempty"` + ArchiveReason string `gorm:"size:255" json:"archive_reason,omitempty"` + + DownloadedEpisodes int `gorm:"-" json:"downloaded_episodes,omitempty"` + LocalMediaCount int `gorm:"-" json:"local_media_count,omitempty"` + MissingEpisodes []int `gorm:"-" json:"missing_episodes,omitempty"` + InLibrary bool `gorm:"-" json:"in_library"` +} diff --git a/internal/model/library_media.go b/internal/model/library_media.go new file mode 100644 index 0000000..256709a --- /dev/null +++ b/internal/model/library_media.go @@ -0,0 +1,99 @@ +package model + +// Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。 +type Library struct { + Base + Name string `gorm:"size:128;not null" json:"name"` + Path string `gorm:"size:1024;not null" json:"path"` + Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music + Enabled bool `gorm:"default:true" json:"enabled"` + Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"` +} + +// LibraryRoot 是逻辑媒体库下的一条真实物理/挂载路径。 +type LibraryRoot struct { + Base + LibraryID string `gorm:"index;size:36;not null" json:"library_id"` + Name string `gorm:"size:128" json:"name,omitempty"` + Path string `gorm:"size:1024;not null" json:"path"` + Enabled bool `gorm:"default:true" json:"enabled"` + SortOrder int `gorm:"default:0" json:"sort_order"` +} + +// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。 +type Media struct { + Base + LibraryID string `gorm:"index;size:36" json:"library_id"` + LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"` + SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"` + Title string `gorm:"size:255;not null" json:"title"` + OriginalName string `gorm:"size:255" json:"original_name,omitempty"` + EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"` + Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"` + RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"` + SizeBytes int64 `json:"size_bytes"` + DurationSec int `json:"duration_sec"` + Width int `json:"width"` + Height int `json:"height"` + VideoCodec string `gorm:"size:32" json:"video_codec,omitempty"` + AudioCodec string `gorm:"size:32" json:"audio_codec,omitempty"` + Container string `gorm:"size:128" json:"container,omitempty"` + PosterURL string `gorm:"size:1024" json:"poster_url,omitempty"` + BackdropURL string `gorm:"size:1024" json:"backdrop_url,omitempty"` + Overview string `gorm:"type:text" json:"overview,omitempty"` + Rating float32 `json:"rating"` + Year int `json:"year"` + SeasonNum int `json:"season_num"` + EpisodeNum int `json:"episode_num"` + ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"` + TMDbID int `json:"tmdb_id"` + BangumiID int `json:"bangumi_id"` + DoubanID string `gorm:"column:douban_id;size:32" json:"douban_id,omitempty"` + TheTVDBID string `gorm:"column:thetvdb_id;size:64" json:"thetvdb_id,omitempty"` + Languages string `gorm:"size:64" json:"languages,omitempty"` // 逗号分隔的 ISO 639-1 代码,如 "zh,en" + Countries string `gorm:"size:128" json:"countries,omitempty"` // 逗号分隔的 ISO 3166-1,如 "CN,US" + Genres string `gorm:"type:text" json:"genres,omitempty"` // 逗号分隔的类型名,如 "Action,Animation" + NSFW bool `gorm:"default:false" json:"nsfw"` + + // STRMURL is the indirection target for .strm files: when present the + // stream handler redirects to it instead of opening the local file. + // Used to expose WebDAV / Alist / S3 / HTTP direct links as media items. + STRMURL string `gorm:"size:2048" json:"strm_url,omitempty"` + + LibraryName string `gorm:"-" json:"library_name,omitempty"` + LibraryPath string `gorm:"-" json:"library_path,omitempty"` + + DisplayLibraryID string `gorm:"-" json:"display_library_id,omitempty"` + DisplayLibraryName string `gorm:"-" json:"display_library_name,omitempty"` + DisplayLibraryPath string `gorm:"-" json:"display_library_path,omitempty"` + + // FileHash is a sparse-sample MD5 used for duplicate detection. + // Computed on-demand by the duplicate finder; format: "-". + FileHash string `gorm:"index;size:64" json:"file_hash,omitempty"` + + // FileID is a "device:inode" identity for the underlying file. Hardlinks + // to the same data share a FileID, letting the scanner skip re-importing a + // seeding source kept by keep_seeding and its organized hardlink as two + // separate items (avoids duplicate rows + double-counted storage). + FileID string `gorm:"index;size:64" json:"file_id,omitempty"` + + // IsDuplicate flags this media as a duplicate of another media row. + IsDuplicate bool `gorm:"default:false" json:"is_duplicate"` + DuplicateOf string `gorm:"size:128" json:"duplicate_of,omitempty"` +} + +// Series 将属于同一节目的剧集分组。 +type Series struct { + Base + LibraryID string `gorm:"index;size:36" json:"library_id"` + Title string `gorm:"size:255;not null" json:"title"` + PosterURL string `gorm:"size:1024" json:"poster_url,omitempty"` + BackdropURL string `gorm:"size:1024" json:"backdrop_url,omitempty"` + Overview string `gorm:"type:text" json:"overview,omitempty"` + Rating float32 `json:"rating"` + Year int `json:"year"` + TMDbID int `json:"tmdb_id"` + BangumiID int `json:"bangumi_id"` + DoubanID string `gorm:"column:douban_id;size:32" json:"douban_id,omitempty"` + TheTVDBID string `gorm:"column:thetvdb_id;size:64" json:"thetvdb_id,omitempty"` +} diff --git a/internal/model/model.go b/internal/model/model.go index a9d8169..28df1bd 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -29,337 +29,6 @@ func (b *Base) BeforeCreate(_ *gorm.DB) error { return nil } -// User 是本地账户。第一个注册的管理员(或种子管理员)获得 "admin" 角色; -// 其他所有用户默认为 "user"。 -type User struct { - Base - Username string `gorm:"uniqueIndex;size:64;not null" json:"username"` - PasswordHash string `gorm:"size:128;not null" json:"-"` - Role string `gorm:"size:16;not null;default:user" json:"role"` - Tier string `gorm:"size:16;default:free" json:"tier"` // free / plus - Nickname string `gorm:"size:128" json:"nickname,omitempty"` - Email string `gorm:"size:128" json:"email,omitempty"` - AvatarURL string `gorm:"size:255" json:"avatar_url,omitempty"` - HideAdult bool `gorm:"default:true" json:"hide_adult"` - ForcePasswordReset bool `gorm:"default:false" json:"force_password_reset"` - IsActive bool `gorm:"default:true" json:"is_active"` - LastLoginAt *time.Time `json:"last_login_at,omitempty"` - // ExpiredAt is the account expiry time. Nil means the account never - // expires. When set and in the past, the account is treated as expired - // (login blocked) until an admin or a redemption code renews it. - ExpiredAt *time.Time `json:"expired_at,omitempty"` - // ShareWarnings counts anti-account-sharing warnings, mainly device - // fingerprint mismatches. Once it exceeds the configured threshold a - // re-offence disables the account until an admin re-enables it. - ShareWarnings int `gorm:"default:0" json:"share_warnings"` - LastShareWarnAt *time.Time `json:"last_share_warn_at,omitempty"` - IsDefaultAdmin bool `gorm:"-" json:"is_default_admin,omitempty"` - IsProtected bool `gorm:"-" json:"is_protected,omitempty"` - RealtimeOnline bool `gorm:"-" json:"realtime_online,omitempty"` - RealtimeDeviceCount int `gorm:"-" json:"realtime_device_count,omitempty"` -} - -// Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。 -type Library struct { - Base - Name string `gorm:"size:128;not null" json:"name"` - Path string `gorm:"size:1024;not null" json:"path"` - Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music - Enabled bool `gorm:"default:true" json:"enabled"` - Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"` -} - -// LibraryRoot 是逻辑媒体库下的一条真实物理/挂载路径。 -type LibraryRoot struct { - Base - LibraryID string `gorm:"index;size:36;not null" json:"library_id"` - Name string `gorm:"size:128" json:"name,omitempty"` - Path string `gorm:"size:1024;not null" json:"path"` - Enabled bool `gorm:"default:true" json:"enabled"` - SortOrder int `gorm:"default:0" json:"sort_order"` -} - -// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。 -type Media struct { - Base - LibraryID string `gorm:"index;size:36" json:"library_id"` - LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"` - SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"` - Title string `gorm:"size:255;not null" json:"title"` - OriginalName string `gorm:"size:255" json:"original_name,omitempty"` - EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"` - Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"` - RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"` - SizeBytes int64 `json:"size_bytes"` - DurationSec int `json:"duration_sec"` - Width int `json:"width"` - Height int `json:"height"` - VideoCodec string `gorm:"size:32" json:"video_codec,omitempty"` - AudioCodec string `gorm:"size:32" json:"audio_codec,omitempty"` - Container string `gorm:"size:128" json:"container,omitempty"` - PosterURL string `gorm:"size:1024" json:"poster_url,omitempty"` - BackdropURL string `gorm:"size:1024" json:"backdrop_url,omitempty"` - Overview string `gorm:"type:text" json:"overview,omitempty"` - Rating float32 `json:"rating"` - Year int `json:"year"` - SeasonNum int `json:"season_num"` - EpisodeNum int `json:"episode_num"` - ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"` - TMDbID int `json:"tmdb_id"` - BangumiID int `json:"bangumi_id"` - DoubanID string `gorm:"column:douban_id;size:32" json:"douban_id,omitempty"` - TheTVDBID string `gorm:"column:thetvdb_id;size:64" json:"thetvdb_id,omitempty"` - Languages string `gorm:"size:64" json:"languages,omitempty"` // 逗号分隔的 ISO 639-1 代码,如 "zh,en" - Countries string `gorm:"size:128" json:"countries,omitempty"` // 逗号分隔的 ISO 3166-1,如 "CN,US" - Genres string `gorm:"type:text" json:"genres,omitempty"` // 逗号分隔的类型名,如 "Action,Animation" - NSFW bool `gorm:"default:false" json:"nsfw"` - - // STRMURL is the indirection target for .strm files: when present the - // stream handler redirects to it instead of opening the local file. - // Used to expose WebDAV / Alist / S3 / HTTP direct links as media items. - STRMURL string `gorm:"size:2048" json:"strm_url,omitempty"` - - LibraryName string `gorm:"-" json:"library_name,omitempty"` - LibraryPath string `gorm:"-" json:"library_path,omitempty"` - - DisplayLibraryID string `gorm:"-" json:"display_library_id,omitempty"` - DisplayLibraryName string `gorm:"-" json:"display_library_name,omitempty"` - DisplayLibraryPath string `gorm:"-" json:"display_library_path,omitempty"` - - // FileHash is a sparse-sample MD5 used for duplicate detection. - // Computed on-demand by the duplicate finder; format: "-". - FileHash string `gorm:"index;size:64" json:"file_hash,omitempty"` - - // FileID is a "device:inode" identity for the underlying file. Hardlinks - // to the same data share a FileID, letting the scanner skip re-importing a - // seeding source kept by keep_seeding and its organized hardlink as two - // separate items (avoids duplicate rows + double-counted storage). - FileID string `gorm:"index;size:64" json:"file_id,omitempty"` - - // IsDuplicate flags this media as a duplicate of another media row. - IsDuplicate bool `gorm:"default:false" json:"is_duplicate"` - DuplicateOf string `gorm:"size:128" json:"duplicate_of,omitempty"` -} - -// APIConfig stores third-party data-source configuration. The api_key -// column is encrypted with AES-GCM (see internal/service/crypto.go) so an -// SQLite leak does not expose third-party credentials. -// -// Provider values mirror the original Python project: -// -// tmdb — themoviedb.org -// bangumi — bgm.tv -// thetvdb — thetvdb.com -// fanart — fanart.tv -// douban — douban.com (cookie) -// openai — OpenAI / DeepSeek / Qwen / Ollama (compatible) -type APIConfig struct { - Base - Provider string `gorm:"uniqueIndex;size:32;not null" json:"provider"` - APIKey string `gorm:"type:text" json:"-"` // ciphertext (never serialised) - BaseURL string `gorm:"size:512" json:"base_url,omitempty"` - Extra string `gorm:"type:text" json:"extra,omitempty"` // free-form JSON - Enabled bool `gorm:"default:true" json:"enabled"` - Description string `gorm:"size:255" json:"description,omitempty"` -} - -// Series 将属于同一节目的剧集分组。 -type Series struct { - Base - LibraryID string `gorm:"index;size:36" json:"library_id"` - Title string `gorm:"size:255;not null" json:"title"` - PosterURL string `gorm:"size:1024" json:"poster_url,omitempty"` - BackdropURL string `gorm:"size:1024" json:"backdrop_url,omitempty"` - Overview string `gorm:"type:text" json:"overview,omitempty"` - Rating float32 `json:"rating"` - Year int `json:"year"` - TMDbID int `json:"tmdb_id"` - BangumiID int `json:"bangumi_id"` - DoubanID string `gorm:"column:douban_id;size:32" json:"douban_id,omitempty"` - TheTVDBID string `gorm:"column:thetvdb_id;size:64" json:"thetvdb_id,omitempty"` -} - -// PlaybackHistory 记录当前播放位置以支持续播。 -type PlaybackHistory struct { - Base - UserID string `gorm:"index;size:36;not null" json:"user_id"` - MediaID string `gorm:"index;size:128;not null" json:"media_id"` - PositionMs int64 `json:"position_ms"` - DurationMs int64 `json:"duration_ms"` - WatchedAt time.Time `json:"watched_at"` - Completed bool `json:"completed"` -} - -// Favorite 将媒体项标记为给定用户的收藏。 -type Favorite struct { - Base - UserID string `gorm:"index;size:36;not null;uniqueIndex:uniq_user_media" json:"user_id"` - MediaID string `gorm:"index;size:128;not null;uniqueIndex:uniq_user_media" json:"media_id"` -} - -// Playlist 是用户策划的、有序的媒体列表。 -type Playlist struct { - Base - UserID string `gorm:"index;size:36;not null" json:"user_id"` - Name string `gorm:"size:128;not null" json:"name"` - IsPublic bool `gorm:"default:false" json:"is_public"` -} - -// PlaylistItem 是 Playlist 和 Media 的连接表,带有排序。 -type PlaylistItem struct { - Base - PlaylistID string `gorm:"index;size:36;not null" json:"playlist_id"` - MediaID string `gorm:"index;size:128;not null" json:"media_id"` - Position int `json:"position"` -} - -// DownloadTask 是待处理(或已完成)的 torrent / HTTP 下载。 -type DownloadTask struct { - Base - UserID string `gorm:"index;size:36" json:"user_id"` - SubscriptionID string `gorm:"index;size:36" json:"subscription_id,omitempty"` - Source string `gorm:"size:32;not null" json:"source"` // qbittorrent / transmission / http - URL string `gorm:"size:2048;not null" json:"-"` - Title string `gorm:"size:512" json:"title,omitempty"` - PosterURL string `gorm:"size:2048" json:"poster_url,omitempty"` - BackdropURL string `gorm:"size:2048" json:"backdrop_url,omitempty"` - Overview string `gorm:"type:text" json:"overview,omitempty"` - SavePath string `gorm:"size:1024" json:"save_path"` - MediaType string `gorm:"size:16" json:"media_type,omitempty"` - MediaCategory string `gorm:"size:128" json:"media_category,omitempty"` - // 媒体展示元数据(用于 Telegram 富通知模板等):原始片名/语言/年份/评分/类型。 - OriginalName string `gorm:"size:512" json:"original_name,omitempty"` - OriginalLanguage string `gorm:"size:32" json:"original_language,omitempty"` - Year int `json:"year,omitempty"` - Rating float32 `json:"rating,omitempty"` - Genres string `gorm:"size:255" json:"genres,omitempty"` // comma separated - Status string `gorm:"size:32;default:queued" json:"status"` - Progress float32 `json:"progress"` - - // AllowExistingLibrary is true for subscription wash/upgrade tasks that are - // allowed to replace an existing library item after download completion. - AllowExistingLibrary bool `gorm:"default:false" json:"allow_existing_library,omitempty"` -} - -// Subscription 是自动化规则,轮询 RSS 源并将匹配种子排队到配置的下载客户端。 -type Subscription struct { - Base - UserID string `gorm:"index;size:36" json:"user_id"` - Name string `gorm:"size:128;not null" json:"name"` - FeedURL string `gorm:"size:2048;not null" json:"feed_url"` - Filter string `gorm:"size:512" json:"filter"` - MediaType string `gorm:"size:16" json:"media_type,omitempty"` - MediaCategory string `gorm:"size:128" json:"media_category,omitempty"` - SavePath string `gorm:"size:1024" json:"save_path,omitempty"` - SearchMode string `gorm:"size:16;default:keyword" json:"search_mode,omitempty"` // keyword / imdb - IMDBID string `gorm:"size:32" json:"imdb_id,omitempty"` - Source string `gorm:"size:32" json:"source,omitempty"` - PosterURL string `gorm:"size:2048" json:"poster_url,omitempty"` - BackdropURL string `gorm:"size:2048" json:"backdrop_url,omitempty"` - Overview string `gorm:"type:text" json:"overview,omitempty"` - // 媒体展示元数据(用于 Telegram 富通知模板等):原始片名/语言/年份/评分/类型。 - OriginalName string `gorm:"size:512" json:"original_name,omitempty"` - OriginalLanguage string `gorm:"size:32" json:"original_language,omitempty"` - Year int `json:"year,omitempty"` - Rating float32 `json:"rating,omitempty"` - Genres string `gorm:"size:255" json:"genres,omitempty"` // comma separated - Resolution string `gorm:"size:32" json:"resolution,omitempty"` // 2160p / 1080p / 720p / best - Quality string `gorm:"size:64" json:"quality,omitempty"` // remux / bluray / web-dl / hdtv - Effects string `gorm:"size:128" json:"effects,omitempty"` // hdr,dolby-vision,atmos - ReleaseGroups string `gorm:"size:255" json:"release_groups,omitempty"` // comma separated - ExcludeWords string `gorm:"size:255" json:"exclude_words,omitempty"` // comma separated - WashEnabled bool `gorm:"default:false" json:"wash_enabled"` - WashPriority string `gorm:"size:32" json:"wash_priority,omitempty"` // balanced / resolution / quality / effects / seeders - TotalEpisodes int `gorm:"default:0" json:"total_episodes,omitempty"` - Priority int `gorm:"default:50" json:"priority,omitempty"` // lower is earlier when schedulers sort later - Enabled bool `gorm:"default:true" json:"enabled"` - LastRunAt *time.Time `json:"last_run_at,omitempty"` - ArchivedAt *time.Time `gorm:"index" json:"archived_at,omitempty"` - ArchiveReason string `gorm:"size:255" json:"archive_reason,omitempty"` - - DownloadedEpisodes int `gorm:"-" json:"downloaded_episodes,omitempty"` - LocalMediaCount int `gorm:"-" json:"local_media_count,omitempty"` - MissingEpisodes []int `gorm:"-" json:"missing_episodes,omitempty"` - InLibrary bool `gorm:"-" json:"in_library"` -} - -// Setting 是单个键/值系统级偏好(供管理 UI 使用)。 -type Setting struct { - Key string `gorm:"primaryKey;size:128" json:"key"` - Value string `gorm:"type:text" json:"value"` - UpdatedAt time.Time `json:"updated_at"` -} - -// AccessLog 是结构化审计跟踪条目。存储在 SQLite 中供管理活动面板使用。 -type AccessLog struct { - Base - UserID string `gorm:"index;size:36" json:"user_id"` - Action string `gorm:"size:64;not null" json:"action"` - Target string `gorm:"size:255" json:"target"` - IP string `gorm:"size:64" json:"ip"` - Detail string `gorm:"type:text" json:"detail"` -} - -// PlayProfile lets one user define multiple "viewing personas" with -// different content-rating limits, library access, and player defaults. -// The original Vue project sketched this out as a forward-looking -// feature; we materialise it server-side so the React port can fully -// function without dropping the screen. -// -// AllowedLibraryIDs is a JSON array of library UUIDs (empty = all). -type PlayProfile struct { - Base - UserID string `gorm:"index;size:36;not null" json:"user_id"` - Name string `gorm:"size:64;not null" json:"name"` - IsDefault bool `gorm:"default:false" json:"is_default"` - ContentRatingLimit string `gorm:"size:16" json:"content_rating_limit,omitempty"` - AllowAdult bool `gorm:"default:false" json:"allow_adult"` - RequirePIN bool `gorm:"default:false" json:"require_pin"` - PINHash string `gorm:"size:128" json:"-"` - PreferredSubtitleLang string `gorm:"size:16" json:"preferred_subtitle_lang,omitempty"` - PreferredAudioLang string `gorm:"size:16" json:"preferred_audio_lang,omitempty"` - AutoplayNext bool `gorm:"default:true" json:"autoplay_next"` - SkipIntro bool `gorm:"default:false" json:"skip_intro"` - AllowedLibraryIDs string `gorm:"type:text;default:'[]'" json:"allowed_library_ids"` - TotalWatchTime int64 `gorm:"default:0" json:"total_watch_time"` - LastActiveAt *time.Time `json:"last_active_at,omitempty"` -} - -// StorageConfig holds the connection settings for one external storage -// backend (Alist / S3 / WebDAV). Type column makes the row poly-typed -// — Config is a JSON blob whose shape is determined by Type. -// -// alist → {server, token} -// s3 → {endpoint, region, bucket, access_key, secret_key, force_path_style} -// webdav → {url, username, password} -type StorageConfig struct { - Base - Type string `gorm:"uniqueIndex;size:16;not null" json:"type"` - Config string `gorm:"type:text;not null" json:"-"` // ciphertext - Enabled bool `gorm:"default:true" json:"enabled"` - LastError string `gorm:"size:512" json:"last_error,omitempty"` -} - -// AssistantSession groups a multi-turn chat with the AI assistant. -type AssistantSession struct { - Base - UserID string `gorm:"index;size:36;not null" json:"user_id"` - Title string `gorm:"size:255" json:"title,omitempty"` -} - -// AssistantMessage is one entry in an AssistantSession transcript. -// -// Role is "user" | "assistant" | "system". The optional OperationID -// links a message to an action the assistant proposed (so the UI can -// offer Undo). -type AssistantMessage struct { - Base - SessionID string `gorm:"index;size:36;not null" json:"session_id"` - Role string `gorm:"size:16;not null" json:"role"` - Content string `gorm:"type:text;not null" json:"content"` - OperationID string `gorm:"size:36" json:"operation_id,omitempty"` -} - // AllModels returns the slice consumed by gorm.AutoMigrate. func AllModels() []interface{} { return []interface{}{ diff --git a/internal/model/playback_collection.go b/internal/model/playback_collection.go new file mode 100644 index 0000000..9979045 --- /dev/null +++ b/internal/model/playback_collection.go @@ -0,0 +1,62 @@ +package model + +import "time" + +// PlaybackHistory 记录当前播放位置以支持续播。 +type PlaybackHistory struct { + Base + UserID string `gorm:"index;size:36;not null" json:"user_id"` + MediaID string `gorm:"index;size:128;not null" json:"media_id"` + PositionMs int64 `json:"position_ms"` + DurationMs int64 `json:"duration_ms"` + WatchedAt time.Time `json:"watched_at"` + Completed bool `json:"completed"` +} + +// Favorite 将媒体项标记为给定用户的收藏。 +type Favorite struct { + Base + UserID string `gorm:"index;size:36;not null;uniqueIndex:uniq_user_media" json:"user_id"` + MediaID string `gorm:"index;size:128;not null;uniqueIndex:uniq_user_media" json:"media_id"` +} + +// Playlist 是用户策划的、有序的媒体列表。 +type Playlist struct { + Base + UserID string `gorm:"index;size:36;not null" json:"user_id"` + Name string `gorm:"size:128;not null" json:"name"` + IsPublic bool `gorm:"default:false" json:"is_public"` +} + +// PlaylistItem 是 Playlist 和 Media 的连接表,带有排序。 +type PlaylistItem struct { + Base + PlaylistID string `gorm:"index;size:36;not null" json:"playlist_id"` + MediaID string `gorm:"index;size:128;not null" json:"media_id"` + Position int `json:"position"` +} + +// PlayProfile lets one user define multiple "viewing personas" with +// different content-rating limits, library access, and player defaults. +// The original Vue project sketched this out as a forward-looking +// feature; we materialise it server-side so the React port can fully +// function without dropping the screen. +// +// AllowedLibraryIDs is a JSON array of library UUIDs (empty = all). +type PlayProfile struct { + Base + UserID string `gorm:"index;size:36;not null" json:"user_id"` + Name string `gorm:"size:64;not null" json:"name"` + IsDefault bool `gorm:"default:false" json:"is_default"` + ContentRatingLimit string `gorm:"size:16" json:"content_rating_limit,omitempty"` + AllowAdult bool `gorm:"default:false" json:"allow_adult"` + RequirePIN bool `gorm:"default:false" json:"require_pin"` + PINHash string `gorm:"size:128" json:"-"` + PreferredSubtitleLang string `gorm:"size:16" json:"preferred_subtitle_lang,omitempty"` + PreferredAudioLang string `gorm:"size:16" json:"preferred_audio_lang,omitempty"` + AutoplayNext bool `gorm:"default:true" json:"autoplay_next"` + SkipIntro bool `gorm:"default:false" json:"skip_intro"` + AllowedLibraryIDs string `gorm:"type:text;default:'[]'" json:"allowed_library_ids"` + TotalWatchTime int64 `gorm:"default:0" json:"total_watch_time"` + LastActiveAt *time.Time `json:"last_active_at,omitempty"` +} diff --git a/internal/model/settings_audit.go b/internal/model/settings_audit.go new file mode 100644 index 0000000..44078b0 --- /dev/null +++ b/internal/model/settings_audit.go @@ -0,0 +1,20 @@ +package model + +import "time" + +// Setting 是单个键/值系统级偏好(供管理 UI 使用)。 +type Setting struct { + Key string `gorm:"primaryKey;size:128" json:"key"` + Value string `gorm:"type:text" json:"value"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AccessLog 是结构化审计跟踪条目。存储在 SQLite 中供管理活动面板使用。 +type AccessLog struct { + Base + UserID string `gorm:"index;size:36" json:"user_id"` + Action string `gorm:"size:64;not null" json:"action"` + Target string `gorm:"size:255" json:"target"` + IP string `gorm:"size:64" json:"ip"` + Detail string `gorm:"type:text" json:"detail"` +} diff --git a/internal/model/storage_assistant.go b/internal/model/storage_assistant.go new file mode 100644 index 0000000..79b323b --- /dev/null +++ b/internal/model/storage_assistant.go @@ -0,0 +1,36 @@ +package model + +// StorageConfig holds the connection settings for one external storage +// backend (Alist / S3 / WebDAV). Type column makes the row poly-typed +// — Config is a JSON blob whose shape is determined by Type. +// +// alist → {server, token} +// s3 → {endpoint, region, bucket, access_key, secret_key, force_path_style} +// webdav → {url, username, password} +type StorageConfig struct { + Base + Type string `gorm:"uniqueIndex;size:16;not null" json:"type"` + Config string `gorm:"type:text;not null" json:"-"` // ciphertext + Enabled bool `gorm:"default:true" json:"enabled"` + LastError string `gorm:"size:512" json:"last_error,omitempty"` +} + +// AssistantSession groups a multi-turn chat with the AI assistant. +type AssistantSession struct { + Base + UserID string `gorm:"index;size:36;not null" json:"user_id"` + Title string `gorm:"size:255" json:"title,omitempty"` +} + +// AssistantMessage is one entry in an AssistantSession transcript. +// +// Role is "user" | "assistant" | "system". The optional OperationID +// links a message to an action the assistant proposed (so the UI can +// offer Undo). +type AssistantMessage struct { + Base + SessionID string `gorm:"index;size:36;not null" json:"session_id"` + Role string `gorm:"size:16;not null" json:"role"` + Content string `gorm:"type:text;not null" json:"content"` + OperationID string `gorm:"size:36" json:"operation_id,omitempty"` +} diff --git a/internal/model/user.go b/internal/model/user.go new file mode 100644 index 0000000..9821054 --- /dev/null +++ b/internal/model/user.go @@ -0,0 +1,33 @@ +package model + +import "time" + +// User 是本地账户。第一个注册的管理员(或种子管理员)获得 "admin" 角色; +// 其他所有用户默认为 "user"。 +type User struct { + Base + Username string `gorm:"uniqueIndex;size:64;not null" json:"username"` + PasswordHash string `gorm:"size:128;not null" json:"-"` + Role string `gorm:"size:16;not null;default:user" json:"role"` + Tier string `gorm:"size:16;default:free" json:"tier"` // free / plus + Nickname string `gorm:"size:128" json:"nickname,omitempty"` + Email string `gorm:"size:128" json:"email,omitempty"` + AvatarURL string `gorm:"size:255" json:"avatar_url,omitempty"` + HideAdult bool `gorm:"default:true" json:"hide_adult"` + ForcePasswordReset bool `gorm:"default:false" json:"force_password_reset"` + IsActive bool `gorm:"default:true" json:"is_active"` + LastLoginAt *time.Time `json:"last_login_at,omitempty"` + // ExpiredAt is the account expiry time. Nil means the account never + // expires. When set and in the past, the account is treated as expired + // (login blocked) until an admin or a redemption code renews it. + ExpiredAt *time.Time `json:"expired_at,omitempty"` + // ShareWarnings counts anti-account-sharing warnings, mainly device + // fingerprint mismatches. Once it exceeds the configured threshold a + // re-offence disables the account until an admin re-enables it. + ShareWarnings int `gorm:"default:0" json:"share_warnings"` + LastShareWarnAt *time.Time `json:"last_share_warn_at,omitempty"` + IsDefaultAdmin bool `gorm:"-" json:"is_default_admin,omitempty"` + IsProtected bool `gorm:"-" json:"is_protected,omitempty"` + RealtimeOnline bool `gorm:"-" json:"realtime_online,omitempty"` + RealtimeDeviceCount int `gorm:"-" json:"realtime_device_count,omitempty"` +}