diff --git a/cmd/jellybit/main.go b/cmd/jellybit/main.go index 7a92c53..181e321 100644 --- a/cmd/jellybit/main.go +++ b/cmd/jellybit/main.go @@ -11,6 +11,7 @@ package main import ( "os" "strings" + _ "time/tzdata" // встроенная база zoneinfo: general.timezone работает на любом хосте "git.vakhrushev.me/av/jellybit/internal/logging" ) diff --git a/cmd/jellybit/serve.go b/cmd/jellybit/serve.go index 4fff950..a55f2f4 100644 --- a/cmd/jellybit/serve.go +++ b/cmd/jellybit/serve.go @@ -148,6 +148,10 @@ func runServe(args []string) error { logger.Info("jellyfin rescan enabled", "url", cfg.Jellyfin.URL) } + loc, err := cfg.DisplayLocation() // валидность уже проверена config.Load + if err != nil { + return err + } router, err := httpapi.NewRouter(httpapi.Deps{ Logger: logger, Ingestor: ingestor, @@ -155,6 +159,7 @@ func runServe(args []string) error { Reader: st, Reviewer: wrk, Live: wrk, + Loc: loc, }) if err != nil { return err diff --git a/config.example.toml b/config.example.toml index eded010..2399386 100644 --- a/config.example.toml +++ b/config.example.toml @@ -4,6 +4,10 @@ # config.toml в рабочей директории; путь переопределяется опцией --config=path. # Для локального запуска укажите существующие каталоги и db_path -> ./jellybit.db. +[general] +# Общие настройки приложения. +timezone = "UTC" # таймзона ОТОБРАЖЕНИЯ времени в веб-UI (IANA, напр. "Europe/Moscow"); хранение всегда в UTC. Пусто → UTC + [qbittorrent] url = "http://qbit:8989" # адрес qBittorrent WebUI; в docker-сети — по имени сервиса username = "admin" # логин WebUI diff --git a/docs/conventions/config.md b/docs/conventions/config.md index fd9c0f1..5c60fce 100644 --- a/docs/conventions/config.md +++ b/docs/conventions/config.md @@ -112,9 +112,17 @@ Ansible + Vault). Приложение просто читает TOML — отд - диапазоны: `recognition.auto_confidence_threshold` ∈ [0, 1], `llm.max_retries` ≥ 0; - длительности парсятся (`llm.timeout`, `worker.poll_interval`, …); +- `general.timezone` — распознаваемая IANA-зона (валидируется + `time.LoadLocation`; zoneinfo встроен через `time/tzdata`, поэтому ошибка = + битое имя, а не отсутствие базы в окружении); - включённые секции консистентны: `metadata.tmdb.enabled` → задан `api_key`; `jellyfin.enabled` → заданы `url`+`api_key`; `telegram.enabled` → `token`. +**Таймзоны.** Хранение времени в БД и логи — всегда UTC. Зона **отображения** в +веб-UI задаётся `[general].timezone` (дефолт `UTC`); только она конфигурируема, +на хранение/сортировку/логи не влияет. Бизнес-логика оперирует временем с явным +TZ (см. [CLAUDE.md](../../CLAUDE.md)). + ## Структура в коде - Весь разбор и валидация — в `internal/config`; наружу отдаётся готовая diff --git a/docs/conventions/database.md b/docs/conventions/database.md index 8d7aecd..d12841e 100644 --- a/docs/conventions/database.md +++ b/docs/conventions/database.md @@ -39,8 +39,14 @@ - Enum-поля (`state`, `kind`, …) — обычный `TEXT` без `CHECK`; допустимые значения держит код (`internal/store`). -- Временные метки — `TEXT DEFAULT (datetime('now'))` (UTC), формат - `store.ParseTime`/`FormatTime`. +- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, напр. + `2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет + лексикографическую сортировку TEXT = хронологию (`ORDER BY created_at`). + Единая точка генерации — приложение: `store.Now()` + `store.FormatTime`/ + `ParseTime` (аналогично `ident.NewID` для id); `DEFAULT (datetime('now'))` на + колонках **не используется** (fail-loud при забытой вставке: `NOT NULL` без + дефолта). Зона хранения всегда UTC; таймзона отображения в UI — конфиг + `[general].timezone`. - Миграции — goose (`internal/store/migrations`): SQL-файлы для DDL; Go-миграции (`goose.AddMigrationContext`) — когда нужен код (генерация id, backfill). При изменении структуры обновляем ER-схему diff --git a/docs/specs/database.md b/docs/specs/database.md index eca429f..5094777 100644 --- a/docs/specs/database.md +++ b/docs/specs/database.md @@ -11,12 +11,16 @@ > > Состояние на: миграции `0001_init`, `0002_recognition_plan`, > `0003_source_miss_count`, `0004_candidate_url`, `0005_display_name`, -> `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`). +> `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`), +> `0007_file_link_size`, `0008_rfc3339_time` (метки времени → RFC 3339 UTC, +> `DEFAULT` убран). Назначение таблиц и почему так — [architecture.md](architecture.md) → «Хранилище». Значения `state` и переходы — [workflow.md](workflow.md). Первичные ключи — ULID (TEXT, lowercase), генерятся приложением -(`internal/ident`) — см. [конвенцию](../conventions/database.md). +(`internal/ident`) — см. [конвенцию](../conventions/database.md). Метки времени +(`created_at`/`updated_at`) — TEXT в RFC 3339, UTC (суффикс `Z`); пишет +приложение (`store.Now`/`FormatTime`), без `DEFAULT` на колонках. ## ER-диаграмма @@ -40,15 +44,15 @@ erDiagram TEXT error_msg "nullable" INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)" TEXT source_added_at "nullable; время добавления в qBittorrent (added_on), базис сортировки (миграция 0005)" - TEXT created_at "NOT NULL DEFAULT datetime('now')" - TEXT updated_at "NOT NULL DEFAULT datetime('now')" + TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" + TEXT updated_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" } download_infohash { TEXT download_id PK_FK "NOT NULL; ON DELETE CASCADE; PK(infohash, download_id)" TEXT infohash PK "NOT NULL; lowercase hex (40 — v1, 64 — v2)" TEXT kind "NOT NULL; v1|v2" - TEXT created_at "NOT NULL DEFAULT datetime('now')" + TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" } recognition { @@ -66,14 +70,14 @@ erDiagram TEXT reasons "NOT NULL DEFAULT '[]'; JSON: причины не-авто" TEXT raw_llm "nullable; сырой ответ LLM" TEXT plan "nullable; JSON recognize.Plan (миграция 0002)" - TEXT created_at "NOT NULL DEFAULT datetime('now')" + TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" } hint { TEXT id PK "ULID" TEXT download_id FK "NOT NULL; ON DELETE CASCADE" TEXT text "NOT NULL" - TEXT created_at "NOT NULL DEFAULT datetime('now')" + TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" } override { @@ -81,7 +85,7 @@ erDiagram TEXT download_id FK "NOT NULL; ON DELETE CASCADE" TEXT field "NOT NULL; UNIQUE(download_id, field)" TEXT value "NOT NULL" - TEXT created_at "NOT NULL DEFAULT datetime('now')" + TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" } metadata_candidate { @@ -93,7 +97,7 @@ erDiagram INTEGER year "nullable" TEXT url "nullable; ссылка на страницу на сайте провайдера" INTEGER chosen "NOT NULL DEFAULT 0; 0/1" - TEXT created_at "NOT NULL DEFAULT datetime('now')" + TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" } file_link { @@ -105,7 +109,7 @@ erDiagram TEXT kind "NOT NULL; video|subtitle|..." TEXT status "NOT NULL; linked|..." INTEGER size "NOT NULL DEFAULT 0; размер файла (байт), фолбэк размера раздачи" - TEXT created_at "NOT NULL DEFAULT datetime('now')" + TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" } ``` diff --git a/internal/config/config.go b/internal/config/config.go index dea5e0a..20da638 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ const DefaultPath = "config.toml" // Config — корневая конфигурация сервиса (см. config.example.toml). type Config struct { + General General `toml:"general"` QBittorrent QBittorrent `toml:"qbittorrent"` Paths Paths `toml:"paths"` Storage Storage `toml:"storage"` @@ -30,6 +31,15 @@ type Config struct { Log Log `toml:"log"` } +// General — общие настройки приложения. +type General struct { + // Timezone — таймзона ОТОБРАЖЕНИЯ времени в веб-UI (IANA, напр. + // "Europe/Moscow"). Хранение всегда UTC; настройка влияет только на рендеринг. + // Пусто → UTC. База зон встроена (time/tzdata), поэтому имя валидируется + // одинаково на любом хосте (см. DisplayLocation). + Timezone string `toml:"timezone"` +} + // QBittorrent — доступ к qBittorrent WebUI и раскладка путей загрузок. type QBittorrent struct { URL string `toml:"url"` @@ -152,10 +162,26 @@ func (d *Duration) UnmarshalText(text []byte) error { // Std возвращает обычный time.Duration. func (d Duration) Std() time.Duration { return time.Duration(d) } +// DisplayLocation возвращает таймзону отображения времени в веб-UI (пусто → UTC). +// Ошибка — если имя зоны не распознано; валидируется на старте (validate). +// Зоны доступны на любом хосте: база zoneinfo встроена в бинарь (time/tzdata), +// поэтому ошибка означает именно битое имя, а не отсутствие zoneinfo. +func (c *Config) DisplayLocation() (*time.Location, error) { + if c.General.Timezone == "" { + return time.UTC, nil + } + loc, err := time.LoadLocation(c.General.Timezone) + if err != nil { + return nil, fmt.Errorf("general.timezone %q: %w", c.General.Timezone, err) + } + return loc, nil +} + // Default возвращает конфиг с разумными умолчаниями; значения из файла // перекрывают их при загрузке. func Default() *Config { return &Config{ + General: General{Timezone: "UTC"}, QBittorrent: QBittorrent{ URL: "http://qbit:8989", Username: "admin", @@ -229,6 +255,10 @@ func (c *Config) validate() error { if c.LLM.Type != "openai-compat" { errs = append(errs, fmt.Errorf("unsupported llm.type %q (supported: openai-compat)", c.LLM.Type)) } + // Таймзона отображения: имя должно распознаваться (zoneinfo встроен). + if _, err := c.DisplayLocation(); err != nil { + errs = append(errs, err) + } // Медиа-пути песочницы: абсолютные, без traversal, существующие каталоги. for _, p := range []struct{ name, path string }{ diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6288407..431c171 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) // validCfg возвращает минимально валидный конфиг поверх Default() с медиа-путями @@ -55,6 +56,24 @@ func TestValidate_KeylessLocalLLM(t *testing.T) { } } +// TestDisplayLocation — зона отображения: пусто → UTC, валидная зона грузится, +// zoneinfo встроен (time/tzdata) → доступна на любом хосте. +func TestDisplayLocation(t *testing.T) { + empty := &Config{} + if loc, err := empty.DisplayLocation(); err != nil || loc != time.UTC { + t.Fatalf("пустая зона → UTC, got %v, %v", loc, err) + } + + c := &Config{General: General{Timezone: "Europe/Moscow"}} + loc, err := c.DisplayLocation() + if err != nil { + t.Fatalf("Europe/Moscow должна грузиться (tzdata встроен): %v", err) + } + if loc.String() != "Europe/Moscow" { + t.Fatalf("loc = %q, want Europe/Moscow", loc.String()) + } +} + func TestValidate_Errors(t *testing.T) { cases := []struct { name string @@ -74,6 +93,7 @@ func TestValidate_Errors(t *testing.T) { {"jellyfin enabled no url", func(c *Config) { c.Jellyfin.Enabled = true; c.Jellyfin.URL = "" }, "jellyfin.url"}, {"jellyfin enabled no key", func(c *Config) { c.Jellyfin.Enabled = true; c.Jellyfin.URL = "http://j"; c.Jellyfin.APIKey = "" }, "jellyfin.api_key"}, {"telegram enabled no token", func(c *Config) { c.Telegram.Enabled = true }, "telegram.token"}, + {"bad timezone", func(c *Config) { c.General.Timezone = "Mars/Phobos" }, "general.timezone"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/httpapi/format_test.go b/internal/httpapi/format_test.go index 36dd624..89d8261 100644 --- a/internal/httpapi/format_test.go +++ b/internal/httpapi/format_test.go @@ -10,6 +10,24 @@ import ( "git.vakhrushev.me/av/jellybit/internal/worker" ) +// TestFmtDateZone — дата отображается в переданной таймзоне: полуночное UTC-время +// сдвигается на следующий день под Europe/Moscow (UTC+3), в UTC остаётся прежним. +func TestFmtDateZone(t *testing.T) { + // 2026-06-14 22:30 UTC == 2026-06-15 01:30 MSK. + ts := time.Date(2026, 6, 14, 22, 30, 0, 0, time.UTC) + + if got := fmtDate(ts, time.UTC); got != "2026-06-14" { + t.Fatalf("UTC: got %q, want 2026-06-14", got) + } + msk, err := time.LoadLocation("Europe/Moscow") + if err != nil { + t.Fatalf("load Europe/Moscow (tzdata встроен): %v", err) + } + if got := fmtDate(ts, msk); got != "2026-06-15" { + t.Fatalf("MSK: got %q, want 2026-06-15", got) + } +} + func TestHumanizeAge(t *testing.T) { now := time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC) cases := []struct { diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index d1bca57..c477f96 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -58,6 +58,8 @@ type Deps struct { Reader Reader Reviewer Reviewer Live LiveStatus + // Loc — таймзона отображения дат в веб-UI (хранение всегда UTC). nil → UTC. + Loc *time.Location } type server struct { @@ -89,6 +91,9 @@ func NewRouter(d Deps) (http.Handler, error) { if d.Live == nil { d.Live = noLive{} // источник телеметрии не подключён — деградируем штатно } + if d.Loc == nil { + d.Loc = time.UTC // таймзона отображения не задана — показываем в UTC + } s := &server{deps: d, tmpl: tmpl, assetVer: assetVer} r := chi.NewRouter() @@ -285,7 +290,7 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { now := time.Now() for _, d := range downloads { - v := toView(d, now) + v := s.toView(d, now) // Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг // и размер нужны в любом состоянии, пока торрент есть в qBittorrent. l, ok := s.liveFor(d) @@ -542,7 +547,7 @@ func toDTO(d store.Download) downloadDTO { } } -func toView(d store.Download, now time.Time) downloadView { +func (s *server) toView(d store.Download, now time.Time) downloadView { state := string(d.State) v := downloadView{ ID: d.ID, @@ -561,7 +566,7 @@ func toView(d store.Download, now time.Time) downloadView { // Дата добавления в карточке — всегда (source_added_at → фолбэк created_at, // как в порядке списка); неразбираемое время просто опускаем. if t, ok := addedTime(d); ok { - v.Added = fmtDate(t) + v.Added = fmtDate(t, s.deps.Loc) v.AddedAgo = humanizeAge(t, now) } return v diff --git a/internal/httpapi/live.go b/internal/httpapi/live.go index 91b92fd..f7e247a 100644 --- a/internal/httpapi/live.go +++ b/internal/httpapi/live.go @@ -186,19 +186,11 @@ func fmtRatio(r float64) string { return fmt.Sprintf("%.2f", r) } -// mskLoc — TZ сервера (Europe/Moscow) для показа дат в веб-UI (инвариант «время -// с явным TZ»). Если zoneinfo недоступен в окружении — деградируем до UTC: -// дата остаётся корректной с точностью до смещения, UI не падает. -var mskLoc = func() *time.Location { - if loc, err := time.LoadLocation("Europe/Moscow"); err == nil { - return loc - } - return time.UTC -}() - -// fmtDate — абсолютная дата добавления для карточки в TZ сервера. -func fmtDate(t time.Time) string { - return t.In(mskLoc).Format("2006-01-02") +// fmtDate — абсолютная дата добавления для карточки в таймзоне отображения +// (general.timezone; хранение всегда UTC). loc не бывает nil — NewRouter +// подставляет UTC по умолчанию. +func fmtDate(t time.Time, loc *time.Location) string { + return t.In(loc).Format("2006-01-02") } // humanizeAge — относительная давность («5 дней назад») от now до t. Будущее diff --git a/internal/store/download.go b/internal/store/download.go index 8957eac..2e56a8e 100644 --- a/internal/store/download.go +++ b/internal/store/download.go @@ -115,7 +115,7 @@ type Download struct { SourceMissCount int `db:"source_miss_count"` // SourceAddedAt — время добавления торрента в qBittorrent (added_on), базис // сортировки списка. NULL, пока воркер не наблюдал раздачу. Хранится в - // формате sqliteTimeLayout (UTC), как created_at. + // формате RFC 3339 (UTC, суффикс Z), как created_at. SourceAddedAt sql.NullString `db:"source_added_at"` CreatedAt string `db:"created_at"` UpdatedAt string `db:"updated_at"` @@ -148,22 +148,25 @@ func (d Download) PrimaryInfohash() string { return d.Infohashes[0].Infohash } -// sqliteTimeLayout — формат меток datetime('now') в SQLite (UTC). -const sqliteTimeLayout = "2006-01-02 15:04:05" - -// ParseTime разбирает временную метку SQLite (datetime('now'), всегда UTC). +// ParseTime разбирает временную метку хранилища (RFC 3339, всегда UTC). func ParseTime(s string) (time.Time, error) { - return time.ParseInLocation(sqliteTimeLayout, s, time.UTC) + return time.Parse(time.RFC3339, s) } -// FormatTime форматирует время в формат меток SQLite (datetime('now'), UTC) — -// байт-в-байт тот же, что у created_at/updated_at. Нужен, чтобы лексикографи- -// ческое сравнение строк времени (COALESCE(source_added_at, created_at) в -// сортировке списка) давало верный хронологический порядок. +// FormatTime форматирует время в формат меток хранилища — RFC 3339 в UTC +// (суффикс Z), напр. «2006-01-02T15:04:05Z». Единый формат всех меток; DEFAULT +// в схеме нет, время всегда пишет приложение через Now (см. ниже). Фиксированная +// ширина сохраняет лексикографическое сравнение строк времени = хронологию +// (COALESCE(source_added_at, created_at) в сортировке списка). func FormatTime(t time.Time) string { - return t.UTC().Format(sqliteTimeLayout) + return t.UTC().Format(time.RFC3339) } +// Now — единая точка получения «сейчас» для меток времени store (UTC). Всё время +// в БД генерирует приложение через неё (аналогично ident.NewID для id), а не +// SQLite DEFAULT: один источник формата, тестируемая точка. +func Now() time.Time { return time.Now().UTC() } + // CreatedTime возвращает время создания загрузки как time.Time (UTC). func (d Download) CreatedTime() (time.Time, error) { return ParseTime(d.CreatedAt) } @@ -182,6 +185,7 @@ func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashe if len(norm) == 0 { return nil, fmt.Errorf("create download: no infohash") } + now := FormatTime(Now()) tx, err := s.DB.BeginTxx(ctx, nil) if err != nil { @@ -200,8 +204,8 @@ func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashe // нему создал бы вторую активную задачу. for _, h := range norm { if _, err := tx.ExecContext(ctx, - `INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, ?)`, - existing.ID, h, HashKind(h)); err != nil { + `INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, ?, ?)`, + existing.ID, h, HashKind(h), now); err != nil { return nil, fmt.Errorf("create download: top up infohash: %w", err) } } @@ -216,17 +220,17 @@ func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashe d.ID = ident.NewID() const q = ` -INSERT INTO download (id, source_type, source_ref, display_name, context, state) -VALUES (?, ?, ?, ?, ?, ?)` +INSERT INTO download (id, source_type, source_ref, display_name, context, state, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?)` if _, err := tx.ExecContext(ctx, q, - d.ID, d.SourceType, d.SourceRef, d.DisplayName, d.Context, d.State); err != nil { + d.ID, d.SourceType, d.SourceRef, d.DisplayName, d.Context, d.State, now, now); err != nil { return nil, fmt.Errorf("insert download: %w", err) } d.Infohashes = d.Infohashes[:0] for _, h := range norm { if _, err := tx.ExecContext(ctx, - `INSERT INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, ?)`, - d.ID, h, HashKind(h)); err != nil { + `INSERT INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, ?, ?)`, + d.ID, h, HashKind(h), now); err != nil { return nil, fmt.Errorf("insert download infohash: %w", err) } d.Infohashes = append(d.Infohashes, Infohash{DownloadID: d.ID, Infohash: h, Kind: HashKind(h)}) @@ -286,6 +290,7 @@ func (s *Store) AddInfohashes(ctx context.Context, downloadID string, hashes []s if len(norm) == 0 { return nil } + now := FormatTime(Now()) tx, err := s.DB.BeginTxx(ctx, nil) if err != nil { return fmt.Errorf("add infohashes to %s: begin tx: %w", downloadID, err) @@ -303,8 +308,8 @@ func (s *Store) AddInfohashes(ctx context.Context, downloadID string, hashes []s continue } if _, err := tx.ExecContext(ctx, - `INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, ?)`, - downloadID, h, HashKind(h)); err != nil { + `INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, ?, ?)`, + downloadID, h, HashKind(h), now); err != nil { return fmt.Errorf("add infohash %s to %s: %w", h, downloadID, err) } } @@ -507,9 +512,9 @@ UPDATE download SET state = ?, error_code = ?, error_msg = ?, - updated_at = datetime('now') + updated_at = ? WHERE id = ?` - args := []any{string(state), nullArg(errCode), nullArg(errMsg), id} + args := []any{string(state), nullArg(errCode), nullArg(errMsg), FormatTime(Now()), id} if !reviveOK && !state.IsTerminal() { q += ` AND state NOT IN (` + placeholders(&args, terminalStates) + `)` } diff --git a/internal/store/download_test.go b/internal/store/download_test.go index 9985c72..b82d7ac 100644 --- a/internal/store/download_test.go +++ b/internal/store/download_test.go @@ -320,8 +320,8 @@ func TestActivateExcludesSelf(t *testing.T) { // Легаси/аварийное состояние: у newer тот же хеш h (мимо API — гард // такого не создаст, но обязан не маскировать). if _, err := st.DB.ExecContext(ctx, - `INSERT INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, 'v1')`, - newer, h); err != nil { + `INSERT INTO download_infohash (download_id, infohash, kind, created_at) VALUES (?, ?, 'v1', ?)`, + newer, h, FormatTime(Now())); err != nil { t.Fatal(err) } diff --git a/internal/store/migration_test.go b/internal/store/migration_test.go index 259128d..1dcc7df 100644 --- a/internal/store/migration_test.go +++ b/internal/store/migration_test.go @@ -2,6 +2,7 @@ package store import ( "context" + "database/sql" "testing" "github.com/jmoiron/sqlx" @@ -97,7 +98,9 @@ func TestUlidMigration(t *testing.T) { if len(three.Infohashes) != 0 { t.Fatalf("хеши #3 = %+v, want пусто", three.Infohashes) } - if one.CreatedAt != "2026-01-01 10:00:00" || one.UpdatedAt != "2026-01-01 11:00:00" { + // Метки переформатированы миграцией 0008 в RFC 3339 (UTC, суффикс Z) — + // момент сохранён; заодно канарейка, что backfill 0008 отработал на данных. + if one.CreatedAt != "2026-01-01T10:00:00Z" || one.UpdatedAt != "2026-01-01T11:00:00Z" { t.Fatalf("метки #1 = %q / %q", one.CreatedAt, one.UpdatedAt) } @@ -140,3 +143,58 @@ func TestUlidMigration(t *testing.T) { } } } + +// TestRFC3339TimeMigration проверяет миграцию 0008: при Up метки переформатиру- +// ются из datetime('now') (пробел) в RFC 3339 (UTC, суффикс Z), при Down — обра- +// тно; NULL source_added_at не затрагивается, момент времени сохраняется. +func TestRFC3339TimeMigration(t *testing.T) { + dbPath := t.TempDir() + "/time.db" + db, err := sqlx.Connect("sqlite", + "file:"+dbPath+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)") + if err != nil { + t.Fatalf("open: %v", err) + } + defer func() { _ = db.Close() }() + + goose.SetBaseFS(migrationsFS) + goose.SetLogger(goose.NopLogger()) + if err := goose.SetDialect("sqlite3"); err != nil { + t.Fatal(err) + } + // Схема до 0008: DEFAULT (datetime('now')), формат меток с пробелом. + if err := goose.UpTo(db.DB, "migrations", 7); err != nil { + t.Fatalf("up to 7: %v", err) + } + // Строка со старой меткой; source_added_at = NULL (проверяем щажение). + if _, err := db.Exec(`INSERT INTO download (id, source_type, source_ref, state, created_at, updated_at) + VALUES ('d1', 'magnet', 'magnet:x', 'downloading', '2026-03-01 12:00:00', '2026-03-01 13:00:00')`); err != nil { + t.Fatalf("seed: %v", err) + } + + read := func() (string, string, sql.NullString) { + var c, u string + var sa sql.NullString + if err := db.QueryRowx( + `SELECT created_at, updated_at, source_added_at FROM download WHERE id='d1'`). + Scan(&c, &u, &sa); err != nil { + t.Fatalf("read: %v", err) + } + return c, u, sa + } + + // Up 0008 → RFC 3339. + if err := goose.UpTo(db.DB, "migrations", 8); err != nil { + t.Fatalf("up to 8: %v", err) + } + if c, u, sa := read(); c != "2026-03-01T12:00:00Z" || u != "2026-03-01T13:00:00Z" || sa.Valid { + t.Fatalf("после up: %q / %q / sa.Valid=%v, want RFC 3339 и NULL sa", c, u, sa.Valid) + } + + // Down 0008 → формат с пробелом. + if err := goose.DownTo(db.DB, "migrations", 7); err != nil { + t.Fatalf("down to 7: %v", err) + } + if c, u, sa := read(); c != "2026-03-01 12:00:00" || u != "2026-03-01 13:00:00" || sa.Valid { + t.Fatalf("после down: %q / %q / sa.Valid=%v, want формат с пробелом и NULL sa", c, u, sa.Valid) + } +} diff --git a/internal/store/migrations/0008_rfc3339_time.go b/internal/store/migrations/0008_rfc3339_time.go new file mode 100644 index 0000000..b867ffc --- /dev/null +++ b/internal/store/migrations/0008_rfc3339_time.go @@ -0,0 +1,202 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upRFC3339Time, downRFC3339Time) +} + +// upRFC3339Time переводит хранение временных меток на RFC 3339 (UTC, суффикс Z, +// напр. «2006-01-02T15:04:05Z») и убирает `DEFAULT (datetime('now'))` со всех +// колонок: время отныне пишет только приложение (store.Now/FormatTime) — единая +// точка генерации, fail-loud при забытой вставке (NOT NULL без DEFAULT). +// +// SQLite не умеет ALTER COLUMN DROP DEFAULT, поэтому перестраиваем 7 таблиц по +// образцу миграции 0006 (FK включены): создаём *_new без DEFAULT на метках, +// копируем данные с переформатированием меток через strftime (id и связи НЕ +// меняются — момент времени сохраняется, ULID-корреляция цела), удаляем старые +// (дети первыми), переименовываем (SQLite ≥3.25 переписывает REFERENCES), +// пересоздаём индексы и проверяем целостность FK. +func upRFC3339Time(ctx context.Context, tx *sql.Tx) error { + return rebuildTimeColumns(ctx, tx, "", rfc3339Expr) +} + +// downRFC3339Time возвращает прежний формат меток (`YYYY-MM-DD HH:MM:SS`) и +// `DEFAULT (datetime('now'))` на колонках. Обратима без потерь: strftime +// разбирает RFC 3339 (T/Z) и момент времени сохраняется. +func downRFC3339Time(ctx context.Context, tx *sql.Tx) error { + return rebuildTimeColumns(ctx, tx, " DEFAULT (datetime('now'))", sqliteExpr) +} + +// rfc3339Expr / sqliteExpr — SQL-выражение переформатирования метки col в +// целевой формат (strftime корректно разбирает и пробел-, и T/Z-форму на входе). +func rfc3339Expr(col string) string { + return fmt.Sprintf("strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', %s)", col) +} + +func sqliteExpr(col string) string { + return fmt.Sprintf("strftime('%%Y-%%m-%%d %%H:%%M:%%S', %s)", col) +} + +// rebuildTimeColumns перестраивает все таблицы с временными метками: tsDefault — +// суффикс DEFAULT для колонок created_at/updated_at ("" — без дефолта); ts — +// выражение переформатирования метки при копировании. +func rebuildTimeColumns(ctx context.Context, tx *sql.Tx, tsDefault string, ts func(string) string) error { + stmts := make([]string, 0, 28) + stmts = append(stmts, createTimeTables(tsDefault)...) + stmts = append(stmts, copyTimeTables(ts)...) + stmts = append(stmts, dropRenameReindex...) + for _, stmt := range stmts { + if _, err := tx.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("rfc3339 time migration: %q: %w", stmt, err) + } + } + return checkForeignKeys(ctx, tx) +} + +// createTimeTables — CREATE *_new для всех таблиц; d подставляет DEFAULT на +// метки created_at/updated_at (прочие DEFAULT — ”, 0, '[]', 1 — неизменны). +func createTimeTables(d string) []string { + return []string{ + fmt.Sprintf(`CREATE TABLE download_new ( + id TEXT PRIMARY KEY, + source_type TEXT NOT NULL, + source_ref TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + context TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL, + error_code TEXT, + error_msg TEXT, + source_miss_count INTEGER NOT NULL DEFAULT 0, + source_added_at TEXT, + created_at TEXT NOT NULL%[1]s, + updated_at TEXT NOT NULL%[1]s +)`, d), + fmt.Sprintf(`CREATE TABLE download_infohash_new ( + download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE, + infohash TEXT NOT NULL, + kind TEXT NOT NULL, + created_at TEXT NOT NULL%[1]s, + PRIMARY KEY (infohash, download_id) +)`, d), + fmt.Sprintf(`CREATE TABLE recognition_new ( + id TEXT PRIMARY KEY, + download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE, + attempt_no INTEGER NOT NULL DEFAULT 1, + is_current INTEGER NOT NULL DEFAULT 1, + media_type TEXT, + title TEXT, + original_title TEXT, + year INTEGER, + provider TEXT, + provider_id TEXT, + confidence REAL, + reasons TEXT NOT NULL DEFAULT '[]', + raw_llm TEXT, + plan TEXT, + created_at TEXT NOT NULL%[1]s +)`, d), + fmt.Sprintf(`CREATE TABLE hint_new ( + id TEXT PRIMARY KEY, + download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE, + text TEXT NOT NULL, + created_at TEXT NOT NULL%[1]s +)`, d), + fmt.Sprintf(`CREATE TABLE override_new ( + id TEXT PRIMARY KEY, + download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE, + field TEXT NOT NULL, + value TEXT NOT NULL, + created_at TEXT NOT NULL%[1]s, + UNIQUE (download_id, field) +)`, d), + fmt.Sprintf(`CREATE TABLE metadata_candidate_new ( + id TEXT PRIMARY KEY, + recognition_id TEXT NOT NULL REFERENCES recognition_new (id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_id TEXT NOT NULL, + title TEXT, + year INTEGER, + chosen INTEGER NOT NULL DEFAULT 0, + url TEXT, + created_at TEXT NOT NULL%[1]s +)`, d), + fmt.Sprintf(`CREATE TABLE file_link_new ( + id TEXT PRIMARY KEY, + download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE, + apply_batch_id TEXT NOT NULL, + src_path TEXT NOT NULL, + dst_path TEXT NOT NULL, + kind TEXT NOT NULL, + status TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL%[1]s +)`, d), + } +} + +// copyTimeTables — INSERT ... SELECT в *_new с переформатированием меток через +// ts(col). Списки колонок явные; strftime(NULL) = NULL — nullable source_added_at +// корректно щадится. +func copyTimeTables(ts func(string) string) []string { + return []string{ + fmt.Sprintf(`INSERT INTO download_new + (id, source_type, source_ref, display_name, context, state, error_code, + error_msg, source_miss_count, source_added_at, created_at, updated_at) +SELECT id, source_type, source_ref, display_name, context, state, error_code, + error_msg, source_miss_count, %s, %s, %s +FROM download`, ts("source_added_at"), ts("created_at"), ts("updated_at")), + fmt.Sprintf(`INSERT INTO download_infohash_new (download_id, infohash, kind, created_at) +SELECT download_id, infohash, kind, %s FROM download_infohash`, ts("created_at")), + fmt.Sprintf(`INSERT INTO recognition_new + (id, download_id, attempt_no, is_current, media_type, title, original_title, + year, provider, provider_id, confidence, reasons, raw_llm, plan, created_at) +SELECT id, download_id, attempt_no, is_current, media_type, title, original_title, + year, provider, provider_id, confidence, reasons, raw_llm, plan, %s +FROM recognition`, ts("created_at")), + fmt.Sprintf(`INSERT INTO hint_new (id, download_id, text, created_at) +SELECT id, download_id, text, %s FROM hint`, ts("created_at")), + fmt.Sprintf(`INSERT INTO override_new (id, download_id, field, value, created_at) +SELECT id, download_id, field, value, %s FROM override`, ts("created_at")), + fmt.Sprintf(`INSERT INTO metadata_candidate_new + (id, recognition_id, provider, provider_id, title, year, chosen, url, created_at) +SELECT id, recognition_id, provider, provider_id, title, year, chosen, url, %s +FROM metadata_candidate`, ts("created_at")), + fmt.Sprintf(`INSERT INTO file_link_new + (id, download_id, apply_batch_id, src_path, dst_path, kind, status, size, created_at) +SELECT id, download_id, apply_batch_id, src_path, dst_path, kind, status, size, %s +FROM file_link`, ts("created_at")), + } +} + +// dropRenameReindex — удаление старых таблиц (дети первыми, FK включены), +// переименование *_new и пересоздание индексов (канонический набор из 0006). +var dropRenameReindex = []string{ + `DROP TABLE file_link`, + `DROP TABLE metadata_candidate`, + `DROP TABLE override`, + `DROP TABLE hint`, + `DROP TABLE recognition`, + `DROP TABLE download_infohash`, + `DROP TABLE download`, + `ALTER TABLE download_new RENAME TO download`, + `ALTER TABLE download_infohash_new RENAME TO download_infohash`, + `ALTER TABLE recognition_new RENAME TO recognition`, + `ALTER TABLE hint_new RENAME TO hint`, + `ALTER TABLE override_new RENAME TO override`, + `ALTER TABLE metadata_candidate_new RENAME TO metadata_candidate`, + `ALTER TABLE file_link_new RENAME TO file_link`, + `CREATE INDEX idx_download_state ON download (state)`, + `CREATE INDEX idx_download_infohash_download ON download_infohash (download_id)`, + `CREATE INDEX idx_recognition_download ON recognition (download_id)`, + `CREATE INDEX idx_hint_download ON hint (download_id)`, + `CREATE INDEX idx_candidate_recognition ON metadata_candidate (recognition_id)`, + `CREATE INDEX idx_file_link_download ON file_link (download_id)`, + `CREATE INDEX idx_file_link_batch ON file_link (apply_batch_id)`, +} diff --git a/internal/store/recognition.go b/internal/store/recognition.go index 89aa32d..7a5b3d1 100644 --- a/internal/store/recognition.go +++ b/internal/store/recognition.go @@ -71,11 +71,12 @@ func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons [ const q = ` INSERT INTO recognition (id, download_id, attempt_no, is_current, media_type, title, original_title, - year, provider, provider_id, confidence, reasons, raw_llm, plan) -VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + year, provider, provider_id, confidence, reasons, raw_llm, plan, created_at) +VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` if _, err := tx.ExecContext(ctx, q, r.ID, r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle, - r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan); err != nil { + r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan, + FormatTime(Now())); err != nil { return "", fmt.Errorf("insert recognition: %w", err) } if err := tx.Commit(); err != nil { @@ -105,8 +106,8 @@ func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID string) (* // AddHint добавляет текстовую подсказку ревьюера к загрузке. func (s *Store) AddHint(ctx context.Context, downloadID string, text string) error { if _, err := s.DB.ExecContext(ctx, - `INSERT INTO hint (id, download_id, text) VALUES (?, ?, ?)`, - ident.NewID(), downloadID, text); err != nil { + `INSERT INTO hint (id, download_id, text, created_at) VALUES (?, ?, ?, ?)`, + ident.NewID(), downloadID, text, FormatTime(Now())); err != nil { return fmt.Errorf("add hint: %w", err) } return nil @@ -127,9 +128,9 @@ func (s *Store) ListHints(ctx context.Context, downloadID string) ([]string, err // SetOverride пиннит значение поля (upsert по (download_id, field)). func (s *Store) SetOverride(ctx context.Context, downloadID string, field, value string) error { const q = ` -INSERT INTO override (id, download_id, field, value) VALUES (?, ?, ?, ?) +INSERT INTO override (id, download_id, field, value, created_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (download_id, field) DO UPDATE SET value = excluded.value` - if _, err := s.DB.ExecContext(ctx, q, ident.NewID(), downloadID, field, value); err != nil { + if _, err := s.DB.ExecContext(ctx, q, ident.NewID(), downloadID, field, value, FormatTime(Now())); err != nil { return fmt.Errorf("set override %q: %w", field, err) } return nil @@ -181,12 +182,13 @@ func (s *Store) CreateFileLinks(ctx context.Context, links []FileLink) error { } defer func() { _ = tx.Rollback() }() + now := FormatTime(Now()) const q = ` -INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status, size) -VALUES (?, ?, ?, ?, ?, ?, ?, ?)` +INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status, size, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` for _, l := range links { if _, err := tx.ExecContext(ctx, q, - ident.NewID(), l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status, l.Size); err != nil { + ident.NewID(), l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status, l.Size, now); err != nil { return fmt.Errorf("insert file_link: %w", err) } } @@ -328,12 +330,13 @@ func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate) } defer func() { _ = tx.Rollback() }() + now := FormatTime(Now()) const q = ` -INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, year, url) -VALUES (?, ?, ?, ?, ?, ?, ?)` +INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, year, url, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?)` for _, c := range cands { if _, err := tx.ExecContext(ctx, q, - ident.NewID(), c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL); err != nil { + ident.NewID(), c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL, now); err != nil { return fmt.Errorf("insert candidate: %w", err) } } diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index 00186b8..71562f1 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -15,9 +15,9 @@ import ( // фиксированные метки времени для детерминированных таймаут-тестов. const ( - timeNow = "2026-06-14 10:00:00" - timeOld = "2026-06-14 08:00:00" // 2 часа назад - timeRecent = "2026-06-14 09:59:00" // 1 минута назад + timeNow = "2026-06-14T10:00:00Z" + timeOld = "2026-06-14T08:00:00Z" // 2 часа назад + timeRecent = "2026-06-14T09:59:00Z" // 1 минута назад ) type fakeStore struct { diff --git a/openspec/changes/archive/2026-07-04-time-storage-rfc3339/.openspec.yaml b/openspec/changes/archive/2026-07-04-time-storage-rfc3339/.openspec.yaml new file mode 100644 index 0000000..d86f152 --- /dev/null +++ b/openspec/changes/archive/2026-07-04-time-storage-rfc3339/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-04 diff --git a/openspec/changes/archive/2026-07-04-time-storage-rfc3339/design.md b/openspec/changes/archive/2026-07-04-time-storage-rfc3339/design.md new file mode 100644 index 0000000..22e0956 --- /dev/null +++ b/openspec/changes/archive/2026-07-04-time-storage-rfc3339/design.md @@ -0,0 +1,142 @@ +## Context + +Метки времени хранятся как TEXT в формате `datetime('now')` — +`2006-01-02 15:04:05` (UTC по конвенции, но не по значению). Формат выбирался +ради двух свойств: лексикографическая сортировка TEXT = хронология (нужно для +`ORDER BY COALESCE(source_added_at, created_at)` в списке загрузок) и байт-в-байт +совпадение меток из Go (`store.FormatTime`) с метками из `DEFAULT (datetime('now'))`. + +Timestamp-колонки (актуальная схема, миграция `0006`): `download.created_at`, +`download.updated_at`, `download.source_added_at` (nullable), а также `created_at` +в `download_infohash`, `recognition`, `hint`, `override`, `metadata_candidate`, +`file_link` — всего 7 таблиц. Все INSERT в `store` сейчас полагаются на `DEFAULT` +(не перечисляют `created_at`). Одно инлайн-обновление вне хелпера: +`updated_at = datetime('now')` в `download.go:510`. + +Зона отображения зашита в код: `mskLoc` (`Europe/Moscow`) в +`internal/httpapi/live.go:189-201`, используется `fmtDate`/`humanizeAge`. + +## Goals / Non-Goals + +**Goals:** + +- Самоописываемое хранилище: зона зафиксирована в значении (RFC 3339, `Z`). +- Значения — валидный ISO 8601, пригодны напрямую для JSON API / `