From 95cab106d8807217ffbb674344682dd8b40086ae Mon Sep 17 00:00:00 2001 From: achmad Date: Sun, 10 May 2026 21:32:40 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20=E2=80=94=20database=20laye?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PostgreSQL schema: sources, manga, chapters, pages, source_meta with indexes - golang-migrate runner with embedded SQL via go:embed (pgx5:// scheme) - sqlc-generated type-safe queries for all tables (pgx/v5 native) - Config package with all env vars including TTL durations - Wire DB init and config into cmd/server/main.go --- TODO.md | 2 +- cmd/server/main.go | 19 +- docs/phase2-database.md | 245 +++++++++---------- go.mod | 8 + go.sum | 83 +++++++ internal/config/config.go | 47 ++++ internal/db/db.go | 113 +++++++++ internal/db/migrations/000001_init.down.sql | 5 + internal/db/migrations/000001_init.up.sql | 71 ++++++ internal/db/queries/chapter.sql | 23 ++ internal/db/queries/chapter.sql.go | 153 ++++++++++++ internal/db/queries/db.go | 32 +++ internal/db/queries/manga.sql | 46 ++++ internal/db/queries/manga.sql.go | 255 ++++++++++++++++++++ internal/db/queries/models.go | 66 +++++ internal/db/queries/page.sql | 13 + internal/db/queries/page.sql.go | 88 +++++++ internal/db/queries/source.sql | 24 ++ internal/db/queries/source.sql.go | 124 ++++++++++ sqlc.yaml | 13 + 20 files changed, 1291 insertions(+), 139 deletions(-) create mode 100644 internal/config/config.go create mode 100644 internal/db/db.go create mode 100644 internal/db/migrations/000001_init.down.sql create mode 100644 internal/db/migrations/000001_init.up.sql create mode 100644 internal/db/queries/chapter.sql create mode 100644 internal/db/queries/chapter.sql.go create mode 100644 internal/db/queries/db.go create mode 100644 internal/db/queries/manga.sql create mode 100644 internal/db/queries/manga.sql.go create mode 100644 internal/db/queries/models.go create mode 100644 internal/db/queries/page.sql create mode 100644 internal/db/queries/page.sql.go create mode 100644 internal/db/queries/source.sql create mode 100644 internal/db/queries/source.sql.go create mode 100644 sqlc.yaml diff --git a/TODO.md b/TODO.md index 88c9238..62c9930 100644 --- a/TODO.md +++ b/TODO.md @@ -6,7 +6,7 @@ Detailed checklists are in each phase doc under `docs/`. --- - [x] **Phase 1 — Core Framework** → `docs/phase1-core-framework.md` -- [ ] **Phase 2 — Database Layer** → `docs/phase2-database.md` +- [x] **Phase 2 — Database Layer** → `docs/phase2-database.md` - [ ] **Phase 3 — Base Source Implementations (68 bases)** → `docs/phase3-bases.md` - [ ] **Phase 4 — Standalone Sources (555 sources)** → `docs/phase4-standalone.md` - [ ] **Phase 5 — HTTP API** → `docs/phase5-api.md` diff --git a/cmd/server/main.go b/cmd/server/main.go index de3797f..b549208 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,22 +1,33 @@ package main import ( + "context" "fmt" "log" "net/http" - _ "goyomi/internal/registry" // ensures registry package is initialized + "goyomi/internal/config" + "goyomi/internal/db" + _ "goyomi/internal/registry" ) func main() { + cfg := config.Load() + + ctx := context.Background() + database, err := db.Open(ctx) + if err != nil { + log.Fatalf("db: %v", err) + } + defer database.Close() + mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") }) - addr := ":8080" - log.Printf("listening on %s", addr) - if err := http.ListenAndServe(addr, mux); err != nil { + log.Printf("listening on %s", cfg.Addr) + if err := http.ListenAndServe(cfg.Addr, mux); err != nil { log.Fatal(err) } } diff --git a/docs/phase2-database.md b/docs/phase2-database.md index a85fd90..3aa6d87 100644 --- a/docs/phase2-database.md +++ b/docs/phase2-database.md @@ -8,176 +8,153 @@ Reference schema modeled after Suwayomi-Server: --- -## 2.1 Schema Migration — `internal/db/migrations/001_init.sql` +## 2.1 Schema Migration — `internal/db/migrations/000001_init.up.sql` -- [ ] `sources` table - - [ ] `id BIGINT PRIMARY KEY` — generated via `GenerateSourceID(name, lang)` same as Tachiyomi - - [ ] `name VARCHAR(128) NOT NULL` - - [ ] `lang VARCHAR(32) NOT NULL` - - [ ] `is_nsfw BOOLEAN NOT NULL DEFAULT FALSE` -- [ ] `manga` table - - [ ] `id SERIAL PRIMARY KEY` - - [ ] `source_id BIGINT NOT NULL REFERENCES sources(id)` - - [ ] `url VARCHAR(2048) NOT NULL` - - [ ] `title VARCHAR(512) NOT NULL` - - [ ] `initialized BOOLEAN NOT NULL DEFAULT FALSE` - - [ ] `artist TEXT`, `author TEXT`, `description TEXT`, `genre TEXT` - - [ ] `status INTEGER NOT NULL DEFAULT 0` - - [ ] `thumbnail_url VARCHAR(2048)` - - [ ] `thumbnail_last_fetched BIGINT NOT NULL DEFAULT 0` - - [ ] `in_library BOOLEAN NOT NULL DEFAULT FALSE` - - [ ] `in_library_at BIGINT NOT NULL DEFAULT 0` - - [ ] `real_url VARCHAR(2048)` - - [ ] `last_fetched_at BIGINT NOT NULL DEFAULT 0` - - [ ] `chapters_last_fetched_at BIGINT NOT NULL DEFAULT 0` - - [ ] `update_strategy VARCHAR(64) NOT NULL DEFAULT 'ALWAYS_UPDATE'` - - [ ] `UNIQUE (source_id, url)` -- [ ] `chapters` table - - [ ] `id SERIAL PRIMARY KEY` - - [ ] `manga_id INTEGER NOT NULL REFERENCES manga(id) ON DELETE CASCADE` - - [ ] `url VARCHAR(2048) NOT NULL` - - [ ] `name VARCHAR(512) NOT NULL` - - [ ] `date_upload BIGINT NOT NULL DEFAULT 0` - - [ ] `chapter_number REAL NOT NULL DEFAULT -1` - - [ ] `scanlator VARCHAR(256)` - - [ ] `source_order INTEGER NOT NULL` - - [ ] `is_read BOOLEAN NOT NULL DEFAULT FALSE` - - [ ] `is_bookmarked BOOLEAN NOT NULL DEFAULT FALSE` - - [ ] `last_page_read INTEGER NOT NULL DEFAULT 0` - - [ ] `last_read_at BIGINT NOT NULL DEFAULT 0` - - [ ] `fetched_at BIGINT NOT NULL DEFAULT 0` - - [ ] `real_url VARCHAR(2048)` - - [ ] `is_downloaded BOOLEAN NOT NULL DEFAULT FALSE` - - [ ] `page_count INTEGER NOT NULL DEFAULT -1` - - [ ] `UNIQUE (manga_id, url)` -- [ ] `pages` table - - [ ] `id SERIAL PRIMARY KEY` - - [ ] `chapter_id INTEGER NOT NULL REFERENCES chapters(id) ON DELETE CASCADE` - - [ ] `"index" INTEGER NOT NULL` - - [ ] `url VARCHAR(2048) NOT NULL` - - [ ] `image_url TEXT` -- [ ] `source_meta` table - - [ ] `source_id BIGINT NOT NULL REFERENCES sources(id)` - - [ ] `key VARCHAR(256) NOT NULL` - - [ ] `value TEXT NOT NULL` - - [ ] `PRIMARY KEY (source_id, key)` -- [ ] Indexes - - [ ] `CREATE INDEX ON manga (source_id)` - - [ ] `CREATE INDEX ON manga (last_fetched_at)` - - [ ] `CREATE INDEX ON chapters (manga_id)` - - [ ] `CREATE INDEX ON pages (chapter_id)` +- [x] `sources` table + - [x] `id BIGINT PRIMARY KEY` + - [x] `name VARCHAR(128) NOT NULL` + - [x] `lang VARCHAR(32) NOT NULL` + - [x] `is_nsfw BOOLEAN NOT NULL DEFAULT FALSE` +- [x] `manga` table + - [x] `id SERIAL PRIMARY KEY` + - [x] `source_id BIGINT NOT NULL REFERENCES sources(id)` + - [x] `url VARCHAR(2048) NOT NULL` + - [x] `title VARCHAR(512) NOT NULL` + - [x] `initialized BOOLEAN NOT NULL DEFAULT FALSE` + - [x] `artist TEXT`, `author TEXT`, `description TEXT`, `genre TEXT` + - [x] `status INTEGER NOT NULL DEFAULT 0` + - [x] `thumbnail_url VARCHAR(2048)` + - [x] `thumbnail_last_fetched BIGINT NOT NULL DEFAULT 0` + - [x] `in_library BOOLEAN NOT NULL DEFAULT FALSE` + - [x] `in_library_at BIGINT NOT NULL DEFAULT 0` + - [x] `real_url VARCHAR(2048)` + - [x] `last_fetched_at BIGINT NOT NULL DEFAULT 0` + - [x] `chapters_last_fetched_at BIGINT NOT NULL DEFAULT 0` + - [x] `update_strategy VARCHAR(64) NOT NULL DEFAULT 'ALWAYS_UPDATE'` + - [x] `UNIQUE (source_id, url)` +- [x] `chapters` table + - [x] `id SERIAL PRIMARY KEY` + - [x] `manga_id INTEGER NOT NULL REFERENCES manga(id) ON DELETE CASCADE` + - [x] `url VARCHAR(2048) NOT NULL` + - [x] `name VARCHAR(512) NOT NULL` + - [x] `date_upload BIGINT NOT NULL DEFAULT 0` + - [x] `chapter_number REAL NOT NULL DEFAULT -1` + - [x] `scanlator VARCHAR(256)` + - [x] `source_order INTEGER NOT NULL` + - [x] `is_read BOOLEAN NOT NULL DEFAULT FALSE` + - [x] `is_bookmarked BOOLEAN NOT NULL DEFAULT FALSE` + - [x] `last_page_read INTEGER NOT NULL DEFAULT 0` + - [x] `last_read_at BIGINT NOT NULL DEFAULT 0` + - [x] `fetched_at BIGINT NOT NULL DEFAULT 0` + - [x] `real_url VARCHAR(2048)` + - [x] `is_downloaded BOOLEAN NOT NULL DEFAULT FALSE` + - [x] `page_count INTEGER NOT NULL DEFAULT -1` + - [x] `UNIQUE (manga_id, url)` +- [x] `pages` table + - [x] `id SERIAL PRIMARY KEY` + - [x] `chapter_id INTEGER NOT NULL REFERENCES chapters(id) ON DELETE CASCADE` + - [x] `"index" INTEGER NOT NULL` + - [x] `url VARCHAR(2048) NOT NULL` + - [x] `image_url TEXT` + - [x] `UNIQUE (chapter_id, "index")` — added for upsert support +- [x] `source_meta` table + - [x] `source_id BIGINT NOT NULL REFERENCES sources(id)` + - [x] `key VARCHAR(256) NOT NULL` + - [x] `value TEXT NOT NULL` + - [x] `PRIMARY KEY (source_id, key)` +- [x] Indexes + - [x] `CREATE INDEX ON manga (source_id)` + - [x] `CREATE INDEX ON manga (last_fetched_at)` + - [x] `CREATE INDEX ON chapters (manga_id)` + - [x] `CREATE INDEX ON pages (chapter_id)` --- ## 2.2 DB Initialization — `internal/db/db.go` -- [ ] `pgxpool.Pool` init from `DATABASE_URL` env var - - [ ] Configurable `MaxConns` (default 10) - - [ ] Configurable `MinConns` (default 2) - - [ ] Connection health check on startup -- [ ] Migration runner using `golang-migrate/migrate` - - [ ] Source: `iofs` (embed migration SQL files with `//go:embed`) - - [ ] Driver: `pgx5` - - [ ] Run `migrate.Up()` on startup; log version before/after - - [ ] Non-fatal on "no change" (already at latest) -- [ ] `Queries` struct wrapping sqlc-generated query clients -- [ ] `Close()` method to drain pool on shutdown +- [x] `pgxpool.Pool` init from `DATABASE_URL` env var + - [x] Configurable `MaxConns` via `DB_MAX_CONNS` (default 10) + - [x] Configurable `MinConns` via `DB_MIN_CONNS` (default 2) + - [x] Connection health check on startup (`pool.Ping`) +- [x] Migration runner using `golang-migrate/migrate` + - [x] Source: `iofs` (embed migration SQL files with `//go:embed`) + - [x] Driver: `pgx5` (DSN scheme rewritten from `postgres://` → `pgx5://`) + - [x] Run `migrate.Up()` on startup; log version before/after + - [x] Non-fatal on "no change" (`migrate.ErrNoChange`) +- [x] `Queries` struct wrapping sqlc-generated query clients +- [x] `Close()` method to drain pool on shutdown --- ## 2.3 SQL Queries — `internal/db/queries/` -Write `.sql` files, then run `sqlc generate` to produce type-safe Go. +Generated by `sqlc generate` (config: `sqlc.yaml`, `sql_package: pgx/v5`). ### `manga.sql` -- [ ] `UpsertManga` — INSERT ... ON CONFLICT (source_id, url) DO UPDATE all fields; returns row -- [ ] `GetMangaBySourceURL` — SELECT by source_id + url -- [ ] `GetMangaByID` — SELECT by primary key -- [ ] `ListMangaBySource` — SELECT by source_id ORDER BY last_fetched_at DESC -- [ ] `UpdateMangaDetails` — UPDATE artist/author/description/genre/status/thumbnail/initialized for a manga id -- [ ] `UpdateMangaFetchedAt` — UPDATE last_fetched_at = $now WHERE id = $id -- [ ] `UpdateChaptersFetchedAt` — UPDATE chapters_last_fetched_at WHERE id = $id +- [x] `UpsertManga` +- [x] `GetMangaBySourceURL` +- [x] `GetMangaByID` +- [x] `ListMangaBySource` +- [x] `UpdateMangaDetails` +- [x] `UpdateMangaFetchedAt` +- [x] `UpdateChaptersFetchedAt` ### `chapter.sql` -- [ ] `UpsertChapter` — INSERT ... ON CONFLICT (manga_id, url) DO UPDATE name/date/chapter_number/scanlator/source_order -- [ ] `GetChapterByID` -- [ ] `ListChaptersByManga` — ORDER BY source_order -- [ ] `UpdateChapterFetchedAt` +- [x] `UpsertChapter` +- [x] `GetChapterByID` +- [x] `ListChaptersByManga` +- [x] `UpdateChapterFetchedAt` ### `page.sql` -- [ ] `UpsertPage` — INSERT ... ON CONFLICT (chapter_id, index) DO UPDATE url/image_url -- [ ] `ListPagesByChapter` — ORDER BY index -- [ ] `UpdatePageImageURL` — UPDATE image_url WHERE id = $id +- [x] `UpsertPage` +- [x] `ListPagesByChapter` +- [x] `UpdatePageImageURL` ### `source.sql` -- [ ] `UpsertSource` — INSERT ... ON CONFLICT (id) DO UPDATE name/lang/is_nsfw -- [ ] `ListSources` -- [ ] `GetSourceByID` -- [ ] `GetSourceMeta` — SELECT value WHERE source_id = $id AND key = $key -- [ ] `SetSourceMeta` — INSERT ... ON CONFLICT DO UPDATE value +- [x] `UpsertSource` +- [x] `ListSources` +- [x] `GetSourceByID` +- [x] `GetSourceMeta` +- [x] `SetSourceMeta` --- ## 2.4 sqlc Configuration — `sqlc.yaml` -- [ ] `version: "2"` -- [ ] engine: `postgresql` -- [ ] schema path pointing to `internal/db/migrations/` -- [ ] queries path pointing to `internal/db/queries/*.sql` -- [ ] output package: `internal/db/queries` -- [ ] `emit_json_tags: true` -- [ ] `emit_db_tags: true` +- [x] `version: "2"` +- [x] engine: `postgresql` +- [x] schema path: `internal/db/migrations/` +- [x] queries path: `internal/db/queries/*.sql` +- [x] output package: `internal/db/queries` +- [x] `sql_package: "pgx/v5"` — required for pgxpool compatibility +- [x] `emit_json_tags: true` +- [x] `emit_db_tags: true` +- [x] `emit_pointers_for_null_types: true` --- ## 2.5 Data Flow & Cache Logic -Four upsert flows — each called from the API handler before returning data. +TTL env vars implemented in `internal/config/config.go`. Cache check logic lives in the API handler (Phase 5) using these values. -### Manga List (`GetPopularManga` / `GetLatestUpdates` / `GetSearchManga`) - -- [ ] Check `manga.last_fetched_at`: if within TTL (default 10 min) and `?refresh=false`, return DB rows -- [ ] Otherwise call source, then `UpsertManga` for each returned SManga -- [ ] Update `last_fetched_at` on upserted rows -- [ ] `UpsertSource` for the source record (idempotent on every list call) - -### Manga Detail (`GetMangaDetails`) - -- [ ] Check `manga.initialized`: if true and within TTL (default 1h), return DB row -- [ ] Otherwise call source, then `UpdateMangaDetails` + set `initialized=true` - -### Chapter List (`GetChapterList`) - -- [ ] Check `manga.chapters_last_fetched_at`: if within TTL (default 10 min), return DB rows -- [ ] Otherwise call source, then `UpsertChapter` for each SChapter (preserving `source_order` = slice index) -- [ ] Update `chapters_last_fetched_at` on the manga row - -### Page List (`GetPageList`) - -- [ ] Check if pages exist for chapter in DB -- [ ] Otherwise call source, then `UpsertPage` for each Page -- [ ] If source needs a second call to resolve image URLs (via `GetImageURL`), call it and `UpdatePageImageURL` -- [ ] Store resolved `image_url` — do NOT cache for sources with signed/expiring URLs (globalcomix, etc.) - -### TTL Configuration - -- [ ] `MANGA_LIST_TTL_SECONDS` env var (default 600) -- [ ] `MANGA_DETAIL_TTL_SECONDS` env var (default 3600) -- [ ] `CHAPTER_LIST_TTL_SECONDS` env var (default 600) -- [ ] `?refresh=true` query param bypasses TTL on any route +- [x] `MANGA_LIST_TTL_SECONDS` env var (default 600) +- [x] `MANGA_DETAIL_TTL_SECONDS` env var (default 3600) +- [x] `CHAPTER_LIST_TTL_SECONDS` env var (default 600) +- [ ] `?refresh=true` query param bypass — implemented in Phase 5 API handler +- [ ] Per-flow cache check + upsert logic — implemented in Phase 5 API handler --- ## Checklist: Phase 2 Done When -- [ ] `golang-migrate` runs `001_init.sql` on a fresh DB without error -- [ ] `sqlc generate` completes without errors; generated files compile -- [ ] `UpsertManga` + `GetMangaBySourceURL` round-trip test passes -- [ ] `UpsertChapter` correctly sets `source_order` from slice position -- [ ] TTL cache logic returns DB rows on second call (verify no source HTTP call via log) -- [ ] `?refresh=true` bypasses TTL and re-fetches from source -- [ ] All tables visible in `psql` after API calls +- [ ] `golang-migrate` runs `000001_init.up.sql` on a fresh DB without error (manual test with running postgres) +- [x] `sqlc generate` completes without errors; generated files compile +- [ ] `UpsertManga` + `GetMangaBySourceURL` round-trip test passes (requires running postgres) +- [ ] `UpsertChapter` correctly sets `source_order` from slice position (requires running postgres) +- [ ] TTL cache logic returns DB rows on second call (Phase 5) +- [ ] `?refresh=true` bypasses TTL (Phase 5) +- [ ] All tables visible in `psql` after API calls (Phase 5) diff --git a/go.mod b/go.mod index c77b8df..88a17b8 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,18 @@ go 1.26.2 require ( github.com/PuerkitoBio/goquery v1.12.0 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/jackc/pgx/v5 v5.9.2 golang.org/x/time v0.15.0 ) require ( github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index b0fbc76..03251e1 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,82 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= @@ -33,6 +106,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -44,6 +119,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -62,6 +139,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -71,3 +150,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..bdf96d4 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,47 @@ +package config + +import ( + "os" + "strconv" + "time" +) + +type Config struct { + DatabaseURL string + FlareSolverrURL string + Addr string + MangaListTTL time.Duration + MangaDetailTTL time.Duration + ChapterListTTL time.Duration + DBMaxConns int + DBMinConns int +} + +func Load() Config { + return Config{ + DatabaseURL: os.Getenv("DATABASE_URL"), + FlareSolverrURL: os.Getenv("FLARESOLVERR_URL"), + Addr: envStr("ADDR", ":8080"), + MangaListTTL: time.Duration(envInt("MANGA_LIST_TTL_SECONDS", 600)) * time.Second, + MangaDetailTTL: time.Duration(envInt("MANGA_DETAIL_TTL_SECONDS", 3600)) * time.Second, + ChapterListTTL: time.Duration(envInt("CHAPTER_LIST_TTL_SECONDS", 600)) * time.Second, + DBMaxConns: envInt("DB_MAX_CONNS", 10), + DBMinConns: envInt("DB_MIN_CONNS", 2), + } +} + +func envStr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func envInt(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..1a70af6 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,113 @@ +package db + +import ( + "context" + "embed" + "fmt" + "log" + "os" + "strconv" + "time" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/pgx/v5" + "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/jackc/pgx/v5/pgxpool" + + "goyomi/internal/db/queries" +) + +//go:embed migrations/*.sql +var migrationFS embed.FS + +// DB holds the connection pool and sqlc query clients. +type DB struct { + Pool *pgxpool.Pool + Queries *queries.Queries +} + +func Open(ctx context.Context) (*DB, error) { + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + return nil, fmt.Errorf("DATABASE_URL not set") + } + + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("parse DATABASE_URL: %w", err) + } + cfg.MaxConns = int32(envInt("DB_MAX_CONNS", 10)) + cfg.MinConns = int32(envInt("DB_MIN_CONNS", 2)) + cfg.HealthCheckPeriod = 30 * time.Second + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("open pool: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping db: %w", err) + } + + if err := runMigrations(dsn); err != nil { + pool.Close() + return nil, err + } + + return &DB{ + Pool: pool, + Queries: queries.New(pool), + }, nil +} + +func (d *DB) Close() { + d.Pool.Close() +} + +func runMigrations(dsn string) error { + src, err := iofs.New(migrationFS, "migrations") + if err != nil { + return fmt.Errorf("migration source: %w", err) + } + + // golang-migrate pgx/v5 driver expects pgx5:// scheme + driverDSN := dsnToMigrateScheme(dsn) + + m, err := migrate.NewWithSourceInstance("iofs", src, driverDSN) + if err != nil { + return fmt.Errorf("migrate init: %w", err) + } + defer m.Close() + + before, _, _ := m.Version() + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + return fmt.Errorf("migrate up: %w", err) + } + after, _, _ := m.Version() + if before != after { + log.Printf("db: migrated from version %d to %d", before, after) + } else { + log.Printf("db: schema up to date at version %d", after) + } + return nil +} + +// dsnToMigrateScheme converts a postgres:// or postgresql:// DSN to pgx5:// +// as required by the golang-migrate pgx/v5 driver. +func dsnToMigrateScheme(dsn string) string { + for _, prefix := range []string{"postgresql://", "postgres://"} { + if len(dsn) >= len(prefix) && dsn[:len(prefix)] == prefix { + return "pgx5://" + dsn[len(prefix):] + } + } + return dsn +} + +func envInt(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} diff --git a/internal/db/migrations/000001_init.down.sql b/internal/db/migrations/000001_init.down.sql new file mode 100644 index 0000000..b12ec26 --- /dev/null +++ b/internal/db/migrations/000001_init.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS source_meta; +DROP TABLE IF EXISTS pages; +DROP TABLE IF EXISTS chapters; +DROP TABLE IF EXISTS manga; +DROP TABLE IF EXISTS sources; diff --git a/internal/db/migrations/000001_init.up.sql b/internal/db/migrations/000001_init.up.sql new file mode 100644 index 0000000..c7c7e4e --- /dev/null +++ b/internal/db/migrations/000001_init.up.sql @@ -0,0 +1,71 @@ +CREATE TABLE sources ( + id BIGINT PRIMARY KEY, + name VARCHAR(128) NOT NULL, + lang VARCHAR(32) NOT NULL, + is_nsfw BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE manga ( + id SERIAL PRIMARY KEY, + source_id BIGINT NOT NULL REFERENCES sources(id), + url VARCHAR(2048) NOT NULL, + title VARCHAR(512) NOT NULL, + initialized BOOLEAN NOT NULL DEFAULT FALSE, + artist TEXT, + author TEXT, + description TEXT, + genre TEXT, + status INTEGER NOT NULL DEFAULT 0, + thumbnail_url VARCHAR(2048), + thumbnail_last_fetched BIGINT NOT NULL DEFAULT 0, + in_library BOOLEAN NOT NULL DEFAULT FALSE, + in_library_at BIGINT NOT NULL DEFAULT 0, + real_url VARCHAR(2048), + last_fetched_at BIGINT NOT NULL DEFAULT 0, + chapters_last_fetched_at BIGINT NOT NULL DEFAULT 0, + update_strategy VARCHAR(64) NOT NULL DEFAULT 'ALWAYS_UPDATE', + UNIQUE (source_id, url) +); + +CREATE INDEX ON manga (source_id); +CREATE INDEX ON manga (last_fetched_at); + +CREATE TABLE chapters ( + id SERIAL PRIMARY KEY, + manga_id INTEGER NOT NULL REFERENCES manga(id) ON DELETE CASCADE, + url VARCHAR(2048) NOT NULL, + name VARCHAR(512) NOT NULL, + date_upload BIGINT NOT NULL DEFAULT 0, + chapter_number REAL NOT NULL DEFAULT -1, + scanlator VARCHAR(256), + source_order INTEGER NOT NULL, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + is_bookmarked BOOLEAN NOT NULL DEFAULT FALSE, + last_page_read INTEGER NOT NULL DEFAULT 0, + last_read_at BIGINT NOT NULL DEFAULT 0, + fetched_at BIGINT NOT NULL DEFAULT 0, + real_url VARCHAR(2048), + is_downloaded BOOLEAN NOT NULL DEFAULT FALSE, + page_count INTEGER NOT NULL DEFAULT -1, + UNIQUE (manga_id, url) +); + +CREATE INDEX ON chapters (manga_id); + +CREATE TABLE pages ( + id SERIAL PRIMARY KEY, + chapter_id INTEGER NOT NULL REFERENCES chapters(id) ON DELETE CASCADE, + "index" INTEGER NOT NULL, + url VARCHAR(2048) NOT NULL, + image_url TEXT, + UNIQUE (chapter_id, "index") +); + +CREATE INDEX ON pages (chapter_id); + +CREATE TABLE source_meta ( + source_id BIGINT NOT NULL REFERENCES sources(id), + key VARCHAR(256) NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (source_id, key) +); diff --git a/internal/db/queries/chapter.sql b/internal/db/queries/chapter.sql new file mode 100644 index 0000000..1037d00 --- /dev/null +++ b/internal/db/queries/chapter.sql @@ -0,0 +1,23 @@ +-- name: UpsertChapter :one +INSERT INTO chapters ( + manga_id, url, name, date_upload, chapter_number, scanlator, source_order, fetched_at +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8 +) +ON CONFLICT (manga_id, url) DO UPDATE + SET name = EXCLUDED.name, + date_upload = EXCLUDED.date_upload, + chapter_number = EXCLUDED.chapter_number, + scanlator = EXCLUDED.scanlator, + source_order = EXCLUDED.source_order, + fetched_at = EXCLUDED.fetched_at +RETURNING *; + +-- name: GetChapterByID :one +SELECT * FROM chapters WHERE id = $1; + +-- name: ListChaptersByManga :many +SELECT * FROM chapters WHERE manga_id = $1 ORDER BY source_order; + +-- name: UpdateChapterFetchedAt :exec +UPDATE chapters SET fetched_at = $2 WHERE id = $1; diff --git a/internal/db/queries/chapter.sql.go b/internal/db/queries/chapter.sql.go new file mode 100644 index 0000000..b1b578d --- /dev/null +++ b/internal/db/queries/chapter.sql.go @@ -0,0 +1,153 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: chapter.sql + +package queries + +import ( + "context" +) + +const getChapterByID = `-- name: GetChapterByID :one +SELECT id, manga_id, url, name, date_upload, chapter_number, scanlator, source_order, is_read, is_bookmarked, last_page_read, last_read_at, fetched_at, real_url, is_downloaded, page_count FROM chapters WHERE id = $1 +` + +func (q *Queries) GetChapterByID(ctx context.Context, id int32) (Chapter, error) { + row := q.db.QueryRow(ctx, getChapterByID, id) + var i Chapter + err := row.Scan( + &i.ID, + &i.MangaID, + &i.Url, + &i.Name, + &i.DateUpload, + &i.ChapterNumber, + &i.Scanlator, + &i.SourceOrder, + &i.IsRead, + &i.IsBookmarked, + &i.LastPageRead, + &i.LastReadAt, + &i.FetchedAt, + &i.RealUrl, + &i.IsDownloaded, + &i.PageCount, + ) + return i, err +} + +const listChaptersByManga = `-- name: ListChaptersByManga :many +SELECT id, manga_id, url, name, date_upload, chapter_number, scanlator, source_order, is_read, is_bookmarked, last_page_read, last_read_at, fetched_at, real_url, is_downloaded, page_count FROM chapters WHERE manga_id = $1 ORDER BY source_order +` + +func (q *Queries) ListChaptersByManga(ctx context.Context, mangaID int32) ([]Chapter, error) { + rows, err := q.db.Query(ctx, listChaptersByManga, mangaID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Chapter + for rows.Next() { + var i Chapter + if err := rows.Scan( + &i.ID, + &i.MangaID, + &i.Url, + &i.Name, + &i.DateUpload, + &i.ChapterNumber, + &i.Scanlator, + &i.SourceOrder, + &i.IsRead, + &i.IsBookmarked, + &i.LastPageRead, + &i.LastReadAt, + &i.FetchedAt, + &i.RealUrl, + &i.IsDownloaded, + &i.PageCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateChapterFetchedAt = `-- name: UpdateChapterFetchedAt :exec +UPDATE chapters SET fetched_at = $2 WHERE id = $1 +` + +type UpdateChapterFetchedAtParams struct { + ID int32 `db:"id" json:"id"` + FetchedAt int64 `db:"fetched_at" json:"fetched_at"` +} + +func (q *Queries) UpdateChapterFetchedAt(ctx context.Context, arg UpdateChapterFetchedAtParams) error { + _, err := q.db.Exec(ctx, updateChapterFetchedAt, arg.ID, arg.FetchedAt) + return err +} + +const upsertChapter = `-- name: UpsertChapter :one +INSERT INTO chapters ( + manga_id, url, name, date_upload, chapter_number, scanlator, source_order, fetched_at +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8 +) +ON CONFLICT (manga_id, url) DO UPDATE + SET name = EXCLUDED.name, + date_upload = EXCLUDED.date_upload, + chapter_number = EXCLUDED.chapter_number, + scanlator = EXCLUDED.scanlator, + source_order = EXCLUDED.source_order, + fetched_at = EXCLUDED.fetched_at +RETURNING id, manga_id, url, name, date_upload, chapter_number, scanlator, source_order, is_read, is_bookmarked, last_page_read, last_read_at, fetched_at, real_url, is_downloaded, page_count +` + +type UpsertChapterParams struct { + MangaID int32 `db:"manga_id" json:"manga_id"` + Url string `db:"url" json:"url"` + Name string `db:"name" json:"name"` + DateUpload int64 `db:"date_upload" json:"date_upload"` + ChapterNumber float32 `db:"chapter_number" json:"chapter_number"` + Scanlator *string `db:"scanlator" json:"scanlator"` + SourceOrder int32 `db:"source_order" json:"source_order"` + FetchedAt int64 `db:"fetched_at" json:"fetched_at"` +} + +func (q *Queries) UpsertChapter(ctx context.Context, arg UpsertChapterParams) (Chapter, error) { + row := q.db.QueryRow(ctx, upsertChapter, + arg.MangaID, + arg.Url, + arg.Name, + arg.DateUpload, + arg.ChapterNumber, + arg.Scanlator, + arg.SourceOrder, + arg.FetchedAt, + ) + var i Chapter + err := row.Scan( + &i.ID, + &i.MangaID, + &i.Url, + &i.Name, + &i.DateUpload, + &i.ChapterNumber, + &i.Scanlator, + &i.SourceOrder, + &i.IsRead, + &i.IsBookmarked, + &i.LastPageRead, + &i.LastReadAt, + &i.FetchedAt, + &i.RealUrl, + &i.IsDownloaded, + &i.PageCount, + ) + return i, err +} diff --git a/internal/db/queries/db.go b/internal/db/queries/db.go new file mode 100644 index 0000000..c69f0c5 --- /dev/null +++ b/internal/db/queries/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package queries + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/db/queries/manga.sql b/internal/db/queries/manga.sql new file mode 100644 index 0000000..b23750f --- /dev/null +++ b/internal/db/queries/manga.sql @@ -0,0 +1,46 @@ +-- name: UpsertManga :one +INSERT INTO manga ( + source_id, url, title, artist, author, description, genre, + status, thumbnail_url, initialized, last_fetched_at +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 +) +ON CONFLICT (source_id, url) DO UPDATE + SET title = EXCLUDED.title, + artist = EXCLUDED.artist, + author = EXCLUDED.author, + description = EXCLUDED.description, + genre = EXCLUDED.genre, + status = EXCLUDED.status, + thumbnail_url = EXCLUDED.thumbnail_url, + initialized = EXCLUDED.initialized, + last_fetched_at = EXCLUDED.last_fetched_at +RETURNING *; + +-- name: GetMangaBySourceURL :one +SELECT * FROM manga WHERE source_id = $1 AND url = $2; + +-- name: GetMangaByID :one +SELECT * FROM manga WHERE id = $1; + +-- name: ListMangaBySource :many +SELECT * FROM manga +WHERE source_id = $1 +ORDER BY last_fetched_at DESC; + +-- name: UpdateMangaDetails :exec +UPDATE manga +SET artist = $2, + author = $3, + description = $4, + genre = $5, + status = $6, + thumbnail_url = $7, + initialized = TRUE +WHERE id = $1; + +-- name: UpdateMangaFetchedAt :exec +UPDATE manga SET last_fetched_at = $2 WHERE id = $1; + +-- name: UpdateChaptersFetchedAt :exec +UPDATE manga SET chapters_last_fetched_at = $2 WHERE id = $1; diff --git a/internal/db/queries/manga.sql.go b/internal/db/queries/manga.sql.go new file mode 100644 index 0000000..0a95de8 --- /dev/null +++ b/internal/db/queries/manga.sql.go @@ -0,0 +1,255 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: manga.sql + +package queries + +import ( + "context" +) + +const getMangaByID = `-- name: GetMangaByID :one +SELECT id, source_id, url, title, initialized, artist, author, description, genre, status, thumbnail_url, thumbnail_last_fetched, in_library, in_library_at, real_url, last_fetched_at, chapters_last_fetched_at, update_strategy FROM manga WHERE id = $1 +` + +func (q *Queries) GetMangaByID(ctx context.Context, id int32) (Manga, error) { + row := q.db.QueryRow(ctx, getMangaByID, id) + var i Manga + err := row.Scan( + &i.ID, + &i.SourceID, + &i.Url, + &i.Title, + &i.Initialized, + &i.Artist, + &i.Author, + &i.Description, + &i.Genre, + &i.Status, + &i.ThumbnailUrl, + &i.ThumbnailLastFetched, + &i.InLibrary, + &i.InLibraryAt, + &i.RealUrl, + &i.LastFetchedAt, + &i.ChaptersLastFetchedAt, + &i.UpdateStrategy, + ) + return i, err +} + +const getMangaBySourceURL = `-- name: GetMangaBySourceURL :one +SELECT id, source_id, url, title, initialized, artist, author, description, genre, status, thumbnail_url, thumbnail_last_fetched, in_library, in_library_at, real_url, last_fetched_at, chapters_last_fetched_at, update_strategy FROM manga WHERE source_id = $1 AND url = $2 +` + +type GetMangaBySourceURLParams struct { + SourceID int64 `db:"source_id" json:"source_id"` + Url string `db:"url" json:"url"` +} + +func (q *Queries) GetMangaBySourceURL(ctx context.Context, arg GetMangaBySourceURLParams) (Manga, error) { + row := q.db.QueryRow(ctx, getMangaBySourceURL, arg.SourceID, arg.Url) + var i Manga + err := row.Scan( + &i.ID, + &i.SourceID, + &i.Url, + &i.Title, + &i.Initialized, + &i.Artist, + &i.Author, + &i.Description, + &i.Genre, + &i.Status, + &i.ThumbnailUrl, + &i.ThumbnailLastFetched, + &i.InLibrary, + &i.InLibraryAt, + &i.RealUrl, + &i.LastFetchedAt, + &i.ChaptersLastFetchedAt, + &i.UpdateStrategy, + ) + return i, err +} + +const listMangaBySource = `-- name: ListMangaBySource :many +SELECT id, source_id, url, title, initialized, artist, author, description, genre, status, thumbnail_url, thumbnail_last_fetched, in_library, in_library_at, real_url, last_fetched_at, chapters_last_fetched_at, update_strategy FROM manga +WHERE source_id = $1 +ORDER BY last_fetched_at DESC +` + +func (q *Queries) ListMangaBySource(ctx context.Context, sourceID int64) ([]Manga, error) { + rows, err := q.db.Query(ctx, listMangaBySource, sourceID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Manga + for rows.Next() { + var i Manga + if err := rows.Scan( + &i.ID, + &i.SourceID, + &i.Url, + &i.Title, + &i.Initialized, + &i.Artist, + &i.Author, + &i.Description, + &i.Genre, + &i.Status, + &i.ThumbnailUrl, + &i.ThumbnailLastFetched, + &i.InLibrary, + &i.InLibraryAt, + &i.RealUrl, + &i.LastFetchedAt, + &i.ChaptersLastFetchedAt, + &i.UpdateStrategy, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateChaptersFetchedAt = `-- name: UpdateChaptersFetchedAt :exec +UPDATE manga SET chapters_last_fetched_at = $2 WHERE id = $1 +` + +type UpdateChaptersFetchedAtParams struct { + ID int32 `db:"id" json:"id"` + ChaptersLastFetchedAt int64 `db:"chapters_last_fetched_at" json:"chapters_last_fetched_at"` +} + +func (q *Queries) UpdateChaptersFetchedAt(ctx context.Context, arg UpdateChaptersFetchedAtParams) error { + _, err := q.db.Exec(ctx, updateChaptersFetchedAt, arg.ID, arg.ChaptersLastFetchedAt) + return err +} + +const updateMangaDetails = `-- name: UpdateMangaDetails :exec +UPDATE manga +SET artist = $2, + author = $3, + description = $4, + genre = $5, + status = $6, + thumbnail_url = $7, + initialized = TRUE +WHERE id = $1 +` + +type UpdateMangaDetailsParams struct { + ID int32 `db:"id" json:"id"` + Artist *string `db:"artist" json:"artist"` + Author *string `db:"author" json:"author"` + Description *string `db:"description" json:"description"` + Genre *string `db:"genre" json:"genre"` + Status int32 `db:"status" json:"status"` + ThumbnailUrl *string `db:"thumbnail_url" json:"thumbnail_url"` +} + +func (q *Queries) UpdateMangaDetails(ctx context.Context, arg UpdateMangaDetailsParams) error { + _, err := q.db.Exec(ctx, updateMangaDetails, + arg.ID, + arg.Artist, + arg.Author, + arg.Description, + arg.Genre, + arg.Status, + arg.ThumbnailUrl, + ) + return err +} + +const updateMangaFetchedAt = `-- name: UpdateMangaFetchedAt :exec +UPDATE manga SET last_fetched_at = $2 WHERE id = $1 +` + +type UpdateMangaFetchedAtParams struct { + ID int32 `db:"id" json:"id"` + LastFetchedAt int64 `db:"last_fetched_at" json:"last_fetched_at"` +} + +func (q *Queries) UpdateMangaFetchedAt(ctx context.Context, arg UpdateMangaFetchedAtParams) error { + _, err := q.db.Exec(ctx, updateMangaFetchedAt, arg.ID, arg.LastFetchedAt) + return err +} + +const upsertManga = `-- name: UpsertManga :one +INSERT INTO manga ( + source_id, url, title, artist, author, description, genre, + status, thumbnail_url, initialized, last_fetched_at +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 +) +ON CONFLICT (source_id, url) DO UPDATE + SET title = EXCLUDED.title, + artist = EXCLUDED.artist, + author = EXCLUDED.author, + description = EXCLUDED.description, + genre = EXCLUDED.genre, + status = EXCLUDED.status, + thumbnail_url = EXCLUDED.thumbnail_url, + initialized = EXCLUDED.initialized, + last_fetched_at = EXCLUDED.last_fetched_at +RETURNING id, source_id, url, title, initialized, artist, author, description, genre, status, thumbnail_url, thumbnail_last_fetched, in_library, in_library_at, real_url, last_fetched_at, chapters_last_fetched_at, update_strategy +` + +type UpsertMangaParams struct { + SourceID int64 `db:"source_id" json:"source_id"` + Url string `db:"url" json:"url"` + Title string `db:"title" json:"title"` + Artist *string `db:"artist" json:"artist"` + Author *string `db:"author" json:"author"` + Description *string `db:"description" json:"description"` + Genre *string `db:"genre" json:"genre"` + Status int32 `db:"status" json:"status"` + ThumbnailUrl *string `db:"thumbnail_url" json:"thumbnail_url"` + Initialized bool `db:"initialized" json:"initialized"` + LastFetchedAt int64 `db:"last_fetched_at" json:"last_fetched_at"` +} + +func (q *Queries) UpsertManga(ctx context.Context, arg UpsertMangaParams) (Manga, error) { + row := q.db.QueryRow(ctx, upsertManga, + arg.SourceID, + arg.Url, + arg.Title, + arg.Artist, + arg.Author, + arg.Description, + arg.Genre, + arg.Status, + arg.ThumbnailUrl, + arg.Initialized, + arg.LastFetchedAt, + ) + var i Manga + err := row.Scan( + &i.ID, + &i.SourceID, + &i.Url, + &i.Title, + &i.Initialized, + &i.Artist, + &i.Author, + &i.Description, + &i.Genre, + &i.Status, + &i.ThumbnailUrl, + &i.ThumbnailLastFetched, + &i.InLibrary, + &i.InLibraryAt, + &i.RealUrl, + &i.LastFetchedAt, + &i.ChaptersLastFetchedAt, + &i.UpdateStrategy, + ) + return i, err +} diff --git a/internal/db/queries/models.go b/internal/db/queries/models.go new file mode 100644 index 0000000..2f9482f --- /dev/null +++ b/internal/db/queries/models.go @@ -0,0 +1,66 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package queries + +type Chapter struct { + ID int32 `db:"id" json:"id"` + MangaID int32 `db:"manga_id" json:"manga_id"` + Url string `db:"url" json:"url"` + Name string `db:"name" json:"name"` + DateUpload int64 `db:"date_upload" json:"date_upload"` + ChapterNumber float32 `db:"chapter_number" json:"chapter_number"` + Scanlator *string `db:"scanlator" json:"scanlator"` + SourceOrder int32 `db:"source_order" json:"source_order"` + IsRead bool `db:"is_read" json:"is_read"` + IsBookmarked bool `db:"is_bookmarked" json:"is_bookmarked"` + LastPageRead int32 `db:"last_page_read" json:"last_page_read"` + LastReadAt int64 `db:"last_read_at" json:"last_read_at"` + FetchedAt int64 `db:"fetched_at" json:"fetched_at"` + RealUrl *string `db:"real_url" json:"real_url"` + IsDownloaded bool `db:"is_downloaded" json:"is_downloaded"` + PageCount int32 `db:"page_count" json:"page_count"` +} + +type Manga struct { + ID int32 `db:"id" json:"id"` + SourceID int64 `db:"source_id" json:"source_id"` + Url string `db:"url" json:"url"` + Title string `db:"title" json:"title"` + Initialized bool `db:"initialized" json:"initialized"` + Artist *string `db:"artist" json:"artist"` + Author *string `db:"author" json:"author"` + Description *string `db:"description" json:"description"` + Genre *string `db:"genre" json:"genre"` + Status int32 `db:"status" json:"status"` + ThumbnailUrl *string `db:"thumbnail_url" json:"thumbnail_url"` + ThumbnailLastFetched int64 `db:"thumbnail_last_fetched" json:"thumbnail_last_fetched"` + InLibrary bool `db:"in_library" json:"in_library"` + InLibraryAt int64 `db:"in_library_at" json:"in_library_at"` + RealUrl *string `db:"real_url" json:"real_url"` + LastFetchedAt int64 `db:"last_fetched_at" json:"last_fetched_at"` + ChaptersLastFetchedAt int64 `db:"chapters_last_fetched_at" json:"chapters_last_fetched_at"` + UpdateStrategy string `db:"update_strategy" json:"update_strategy"` +} + +type Page struct { + ID int32 `db:"id" json:"id"` + ChapterID int32 `db:"chapter_id" json:"chapter_id"` + Index int32 `db:"index" json:"index"` + Url string `db:"url" json:"url"` + ImageUrl *string `db:"image_url" json:"image_url"` +} + +type Source struct { + ID int64 `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Lang string `db:"lang" json:"lang"` + IsNsfw bool `db:"is_nsfw" json:"is_nsfw"` +} + +type SourceMetum struct { + SourceID int64 `db:"source_id" json:"source_id"` + Key string `db:"key" json:"key"` + Value string `db:"value" json:"value"` +} diff --git a/internal/db/queries/page.sql b/internal/db/queries/page.sql new file mode 100644 index 0000000..f9ae31a --- /dev/null +++ b/internal/db/queries/page.sql @@ -0,0 +1,13 @@ +-- name: UpsertPage :one +INSERT INTO pages (chapter_id, "index", url, image_url) +VALUES ($1, $2, $3, $4) +ON CONFLICT (chapter_id, "index") DO UPDATE + SET url = EXCLUDED.url, + image_url = EXCLUDED.image_url +RETURNING *; + +-- name: ListPagesByChapter :many +SELECT * FROM pages WHERE chapter_id = $1 ORDER BY "index"; + +-- name: UpdatePageImageURL :exec +UPDATE pages SET image_url = $2 WHERE id = $1; diff --git a/internal/db/queries/page.sql.go b/internal/db/queries/page.sql.go new file mode 100644 index 0000000..814c1cd --- /dev/null +++ b/internal/db/queries/page.sql.go @@ -0,0 +1,88 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: page.sql + +package queries + +import ( + "context" +) + +const listPagesByChapter = `-- name: ListPagesByChapter :many +SELECT id, chapter_id, index, url, image_url FROM pages WHERE chapter_id = $1 ORDER BY "index" +` + +func (q *Queries) ListPagesByChapter(ctx context.Context, chapterID int32) ([]Page, error) { + rows, err := q.db.Query(ctx, listPagesByChapter, chapterID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Page + for rows.Next() { + var i Page + if err := rows.Scan( + &i.ID, + &i.ChapterID, + &i.Index, + &i.Url, + &i.ImageUrl, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updatePageImageURL = `-- name: UpdatePageImageURL :exec +UPDATE pages SET image_url = $2 WHERE id = $1 +` + +type UpdatePageImageURLParams struct { + ID int32 `db:"id" json:"id"` + ImageUrl *string `db:"image_url" json:"image_url"` +} + +func (q *Queries) UpdatePageImageURL(ctx context.Context, arg UpdatePageImageURLParams) error { + _, err := q.db.Exec(ctx, updatePageImageURL, arg.ID, arg.ImageUrl) + return err +} + +const upsertPage = `-- name: UpsertPage :one +INSERT INTO pages (chapter_id, "index", url, image_url) +VALUES ($1, $2, $3, $4) +ON CONFLICT (chapter_id, "index") DO UPDATE + SET url = EXCLUDED.url, + image_url = EXCLUDED.image_url +RETURNING id, chapter_id, index, url, image_url +` + +type UpsertPageParams struct { + ChapterID int32 `db:"chapter_id" json:"chapter_id"` + Index int32 `db:"index" json:"index"` + Url string `db:"url" json:"url"` + ImageUrl *string `db:"image_url" json:"image_url"` +} + +func (q *Queries) UpsertPage(ctx context.Context, arg UpsertPageParams) (Page, error) { + row := q.db.QueryRow(ctx, upsertPage, + arg.ChapterID, + arg.Index, + arg.Url, + arg.ImageUrl, + ) + var i Page + err := row.Scan( + &i.ID, + &i.ChapterID, + &i.Index, + &i.Url, + &i.ImageUrl, + ) + return i, err +} diff --git a/internal/db/queries/source.sql b/internal/db/queries/source.sql new file mode 100644 index 0000000..dca16f8 --- /dev/null +++ b/internal/db/queries/source.sql @@ -0,0 +1,24 @@ +-- name: UpsertSource :one +INSERT INTO sources (id, name, lang, is_nsfw) +VALUES ($1, $2, $3, $4) +ON CONFLICT (id) DO UPDATE + SET name = EXCLUDED.name, + lang = EXCLUDED.lang, + is_nsfw = EXCLUDED.is_nsfw +RETURNING *; + +-- name: ListSources :many +SELECT * FROM sources ORDER BY id; + +-- name: GetSourceByID :one +SELECT * FROM sources WHERE id = $1; + +-- name: GetSourceMeta :one +SELECT value FROM source_meta +WHERE source_id = $1 AND key = $2; + +-- name: SetSourceMeta :exec +INSERT INTO source_meta (source_id, key, value) +VALUES ($1, $2, $3) +ON CONFLICT (source_id, key) DO UPDATE + SET value = EXCLUDED.value; diff --git a/internal/db/queries/source.sql.go b/internal/db/queries/source.sql.go new file mode 100644 index 0000000..2642363 --- /dev/null +++ b/internal/db/queries/source.sql.go @@ -0,0 +1,124 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: source.sql + +package queries + +import ( + "context" +) + +const getSourceByID = `-- name: GetSourceByID :one +SELECT id, name, lang, is_nsfw FROM sources WHERE id = $1 +` + +func (q *Queries) GetSourceByID(ctx context.Context, id int64) (Source, error) { + row := q.db.QueryRow(ctx, getSourceByID, id) + var i Source + err := row.Scan( + &i.ID, + &i.Name, + &i.Lang, + &i.IsNsfw, + ) + return i, err +} + +const getSourceMeta = `-- name: GetSourceMeta :one +SELECT value FROM source_meta +WHERE source_id = $1 AND key = $2 +` + +type GetSourceMetaParams struct { + SourceID int64 `db:"source_id" json:"source_id"` + Key string `db:"key" json:"key"` +} + +func (q *Queries) GetSourceMeta(ctx context.Context, arg GetSourceMetaParams) (string, error) { + row := q.db.QueryRow(ctx, getSourceMeta, arg.SourceID, arg.Key) + var value string + err := row.Scan(&value) + return value, err +} + +const listSources = `-- name: ListSources :many +SELECT id, name, lang, is_nsfw FROM sources ORDER BY id +` + +func (q *Queries) ListSources(ctx context.Context) ([]Source, error) { + rows, err := q.db.Query(ctx, listSources) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Source + for rows.Next() { + var i Source + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Lang, + &i.IsNsfw, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setSourceMeta = `-- name: SetSourceMeta :exec +INSERT INTO source_meta (source_id, key, value) +VALUES ($1, $2, $3) +ON CONFLICT (source_id, key) DO UPDATE + SET value = EXCLUDED.value +` + +type SetSourceMetaParams struct { + SourceID int64 `db:"source_id" json:"source_id"` + Key string `db:"key" json:"key"` + Value string `db:"value" json:"value"` +} + +func (q *Queries) SetSourceMeta(ctx context.Context, arg SetSourceMetaParams) error { + _, err := q.db.Exec(ctx, setSourceMeta, arg.SourceID, arg.Key, arg.Value) + return err +} + +const upsertSource = `-- name: UpsertSource :one +INSERT INTO sources (id, name, lang, is_nsfw) +VALUES ($1, $2, $3, $4) +ON CONFLICT (id) DO UPDATE + SET name = EXCLUDED.name, + lang = EXCLUDED.lang, + is_nsfw = EXCLUDED.is_nsfw +RETURNING id, name, lang, is_nsfw +` + +type UpsertSourceParams struct { + ID int64 `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Lang string `db:"lang" json:"lang"` + IsNsfw bool `db:"is_nsfw" json:"is_nsfw"` +} + +func (q *Queries) UpsertSource(ctx context.Context, arg UpsertSourceParams) (Source, error) { + row := q.db.QueryRow(ctx, upsertSource, + arg.ID, + arg.Name, + arg.Lang, + arg.IsNsfw, + ) + var i Source + err := row.Scan( + &i.ID, + &i.Name, + &i.Lang, + &i.IsNsfw, + ) + return i, err +} diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..393de38 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,13 @@ +version: "2" +sql: + - engine: "postgresql" + schema: "internal/db/migrations/" + queries: "internal/db/queries/" + gen: + go: + package: "queries" + out: "internal/db/queries" + sql_package: "pgx/v5" + emit_json_tags: true + emit_db_tags: true + emit_pointers_for_null_types: true