feat: Phase 2 — database layer

- 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
This commit is contained in:
achmad
2026-05-10 21:32:40 +07:00
parent 85d2ea6143
commit 95cab106d8
20 changed files with 1291 additions and 139 deletions
+1 -1
View File
@@ -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`
+15 -4
View File
@@ -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)
}
}
+111 -134
View File
@@ -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)
+8
View File
@@ -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
)
+83
View File
@@ -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=
+47
View File
@@ -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
}
+113
View File
@@ -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
}
@@ -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;
+71
View File
@@ -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)
);
+23
View File
@@ -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;
+153
View File
@@ -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
}
+32
View File
@@ -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,
}
}
+46
View File
@@ -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;
+255
View File
@@ -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
}
+66
View File
@@ -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"`
}
+13
View File
@@ -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;
+88
View File
@@ -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
}
+24
View File
@@ -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;
+124
View File
@@ -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
}
+13
View File
@@ -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