Files
libnovel/scraper/internal/storage/minio.go
Admin 11a846d043 feat(scraper): rewrite browse storage to domain/html+assets structure, populate ranking from snapshot
- Replace BrowsePageKey(genre/sort/status/type/page) with BrowseHTMLKey(domain, page) -> {domain}/html/page-{n}.html
- Add BrowseCoverKey(domain, slug) -> {domain}/assets/book-covers/{slug}.jpg
- Add SaveBrowseAsset/GetBrowseAsset for binary assets in browse bucket
- Rewrite triggerBrowseSnapshot: after storing HTML, parse it, upsert ranking records with MinIO cover keys, fire per-novel cover download goroutines
- Add handleGetCover endpoint (GET /api/cover/{domain}/{slug}) to proxy cover images from MinIO
- handleGetRanking rewrites MinIO cover keys to /api/cover/... proxy URLs
- Update save-browse CLI to use BrowseHTMLKey, populate ranking, and download covers
2026-03-04 15:13:09 +05:00

316 lines
12 KiB
Go

package storage
import (
"bytes"
"context"
"fmt"
"io"
"strings"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// MinioConfig holds connection parameters for MinIO.
type MinioConfig struct {
Endpoint string // e.g. "minio:9000" — internal address used for all operations
PublicEndpoint string // e.g. "minio.kalekber.cc" — used to sign presigned URLs so browsers can reach them; leave empty to use Endpoint
AccessKey string
SecretKey string
UseSSL bool
PublicUseSSL bool // TLS for the public endpoint (usually true in prod)
BucketChapters string // e.g. "libnovel-chapters"
BucketAudio string // e.g. "libnovel-audio"
BucketBrowse string // e.g. "libnovel-browse"
}
// MinioClient wraps a minio.Client and exposes object operations for
// chapters and audio files.
type MinioClient struct {
c *minio.Client // internal client — used for all read/write operations
pub *minio.Client // public client — used only for generating presigned URLs
cfg MinioConfig
}
// NewMinioClient creates a connected MinIO client and ensures the required
// buckets exist.
func NewMinioClient(ctx context.Context, cfg MinioConfig) (*MinioClient, error) {
c, err := minio.New(cfg.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: cfg.UseSSL,
})
if err != nil {
return nil, fmt.Errorf("minio: new client: %w", err)
}
// Public client: signs presigned URLs with the public hostname so browsers
// can fetch them directly. Falls back to the internal client if no public
// endpoint is configured.
pub := c
if cfg.PublicEndpoint != "" && cfg.PublicEndpoint != cfg.Endpoint {
pub, err = minio.New(cfg.PublicEndpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: cfg.PublicUseSSL,
})
if err != nil {
return nil, fmt.Errorf("minio: new public client: %w", err)
}
}
mc := &MinioClient{c: c, pub: pub, cfg: cfg}
for _, bucket := range []string{cfg.BucketChapters, cfg.BucketAudio, cfg.BucketBrowse} {
if bucket == "" {
continue
}
if err := mc.ensureBucket(ctx, bucket); err != nil {
return nil, err
}
}
return mc, nil
}
// ensureBucket creates a bucket if it does not exist.
func (m *MinioClient) ensureBucket(ctx context.Context, bucket string) error {
exists, err := m.c.BucketExists(ctx, bucket)
if err != nil {
return fmt.Errorf("minio: bucket exists %q: %w", bucket, err)
}
if !exists {
if err := m.c.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil {
return fmt.Errorf("minio: make bucket %q: %w", bucket, err)
}
}
return nil
}
// ─── Chapter objects ──────────────────────────────────────────────────────────
// chapterKey returns the MinIO object key for a chapter.
// Layout: {slug}/vol-{vol}/{lo}-{hi}/chapter-{n}.md
func chapterKey(slug string, vol, n int) string {
const chaptersPerFolder = 50
lo := ((n-1)/chaptersPerFolder)*chaptersPerFolder + 1
hi := lo + chaptersPerFolder - 1
return fmt.Sprintf("%s/vol-%d/%d-%d/chapter-%d.md", slug, vol, lo, hi, n)
}
// PutChapter stores chapter markdown in MinIO.
func (m *MinioClient) PutChapter(ctx context.Context, slug string, vol, n int, content string) error {
key := chapterKey(slug, vol, n)
data := []byte(content)
_, err := m.c.PutObject(ctx, m.cfg.BucketChapters, key,
bytes.NewReader(data), int64(len(data)),
minio.PutObjectOptions{ContentType: "text/markdown; charset=utf-8"})
if err != nil {
return fmt.Errorf("minio: put chapter %s: %w", key, err)
}
return nil
}
// GetChapter retrieves chapter markdown from MinIO.
func (m *MinioClient) GetChapter(ctx context.Context, slug string, vol, n int) (string, error) {
key := chapterKey(slug, vol, n)
obj, err := m.c.GetObject(ctx, m.cfg.BucketChapters, key, minio.GetObjectOptions{})
if err != nil {
return "", fmt.Errorf("minio: get chapter %s: %w", key, err)
}
defer obj.Close()
data, err := io.ReadAll(obj)
if err != nil {
return "", fmt.Errorf("minio: read chapter %s: %w", key, err)
}
return string(data), nil
}
// ChapterExists returns true if the object for this chapter is present.
func (m *MinioClient) ChapterExists(ctx context.Context, slug string, vol, n int) bool {
key := chapterKey(slug, vol, n)
_, err := m.c.StatObject(ctx, m.cfg.BucketChapters, key, minio.StatObjectOptions{})
return err == nil
}
// ListChapterKeys returns all object keys under slug/ in the chapters bucket,
// sorted lexicographically (MinIO returns them in order).
func (m *MinioClient) ListChapterKeys(ctx context.Context, slug string) ([]string, error) {
prefix := slug + "/"
var keys []string
for obj := range m.c.ListObjects(ctx, m.cfg.BucketChapters,
minio.ListObjectsOptions{Prefix: prefix, Recursive: true}) {
if obj.Err != nil {
return nil, fmt.Errorf("minio: list chapters %s: %w", slug, obj.Err)
}
keys = append(keys, obj.Key)
}
return keys, nil
}
// CountChapters returns the number of chapter objects for a slug.
func (m *MinioClient) CountChapters(ctx context.Context, slug string) int {
keys, _ := m.ListChapterKeys(ctx, slug)
return len(keys)
}
// ─── Audio objects ────────────────────────────────────────────────────────────
// AudioObjectKey returns the MinIO key for a cached audio file.
// Key: {slug}/ch{n}-{voice}-{speed:.1f}.mp3
func AudioObjectKey(slug string, n int, voice string, speed float64) string {
safe := sanitiseVoice(voice)
return fmt.Sprintf("%s/ch%d-%s-%.1f.mp3", slug, n, safe, speed)
}
// PutAudio stores an audio file in the audio bucket.
func (m *MinioClient) PutAudio(ctx context.Context, key string, data []byte) error {
_, err := m.c.PutObject(ctx, m.cfg.BucketAudio, key,
bytes.NewReader(data), int64(len(data)),
minio.PutObjectOptions{ContentType: "audio/mpeg"})
if err != nil {
return fmt.Errorf("minio: put audio %s: %w", key, err)
}
return nil
}
// GetAudio retrieves audio bytes from the audio bucket.
func (m *MinioClient) GetAudio(ctx context.Context, key string) ([]byte, error) {
obj, err := m.c.GetObject(ctx, m.cfg.BucketAudio, key, minio.GetObjectOptions{})
if err != nil {
return nil, fmt.Errorf("minio: get audio %s: %w", key, err)
}
defer obj.Close()
return io.ReadAll(obj)
}
// AudioExists returns true if the audio object is present in the bucket.
func (m *MinioClient) AudioExists(ctx context.Context, key string) bool {
_, err := m.c.StatObject(ctx, m.cfg.BucketAudio, key, minio.StatObjectOptions{})
return err == nil
}
// ─── Presigned URLs ───────────────────────────────────────────────────────────
// PresignChapter returns a presigned GET URL for a chapter object signed with
// the internal endpoint — intended for server-side fetches only.
func (m *MinioClient) PresignChapter(ctx context.Context, slug string, vol, n int, expires time.Duration) (string, error) {
key := chapterKey(slug, vol, n)
u, err := m.c.PresignedGetObject(ctx, m.cfg.BucketChapters, key, expires, nil)
if err != nil {
return "", fmt.Errorf("minio: presign chapter %s: %w", key, err)
}
return u.String(), nil
}
// PresignAudio returns a presigned GET URL for an audio object signed with
// the public endpoint so the browser can fetch it directly.
func (m *MinioClient) PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error) {
u, err := m.pub.PresignedGetObject(ctx, m.cfg.BucketAudio, key, expires, nil)
if err != nil {
return "", fmt.Errorf("minio: presign audio %s: %w", key, err)
}
return u.String(), nil
}
// ─── Browse page snapshots ────────────────────────────────────────────────────
//
// New bucket layout (libnovel-browse):
//
// {domain}/html/page-{n}.html — SingleFile HTML snapshot
// {domain}/assets/book-covers/{slug}.jpg — downloaded cover image
//
// The domain segment is derived from the source URL hostname
// (e.g. "novelfire.net"). This makes the bucket self-describing and
// extensible to multiple sources.
// BrowseHTMLKey returns the MinIO object key for a SingleFile HTML snapshot.
// Layout: {domain}/html/page-{n}.html
func BrowseHTMLKey(domain string, page int) string {
return fmt.Sprintf("%s/html/page-%d.html", domain, page)
}
// BrowseCoverKey returns the MinIO object key for a cached book cover image.
// Layout: {domain}/assets/book-covers/{slug}.jpg
func BrowseCoverKey(domain, slug string) string {
return fmt.Sprintf("%s/assets/book-covers/%s.jpg", domain, slug)
}
// PutBrowsePage stores a SingleFile HTML snapshot in the browse bucket.
func (m *MinioClient) PutBrowsePage(ctx context.Context, key, html string) error {
data := []byte(html)
_, err := m.c.PutObject(ctx, m.cfg.BucketBrowse, key,
bytes.NewReader(data), int64(len(data)),
minio.PutObjectOptions{ContentType: "text/html; charset=utf-8"})
if err != nil {
return fmt.Errorf("minio: put browse page %s: %w", key, err)
}
return nil
}
// GetBrowsePage retrieves a SingleFile HTML snapshot from the browse bucket.
// Returns ("", false, nil) when the object does not exist.
func (m *MinioClient) GetBrowsePage(ctx context.Context, key string) (string, bool, error) {
obj, err := m.c.GetObject(ctx, m.cfg.BucketBrowse, key, minio.GetObjectOptions{})
if err != nil {
return "", false, fmt.Errorf("minio: get browse page %s: %w", key, err)
}
defer obj.Close()
// Check whether the object actually exists by inspecting the Stat.
if _, statErr := obj.Stat(); statErr != nil {
return "", false, nil // not found
}
data, err := io.ReadAll(obj)
if err != nil {
return "", false, fmt.Errorf("minio: read browse page %s: %w", key, err)
}
return string(data), true, nil
}
// BrowsePageExists returns true if a snapshot object is present in the browse bucket.
func (m *MinioClient) BrowsePageExists(ctx context.Context, key string) bool {
_, err := m.c.StatObject(ctx, m.cfg.BucketBrowse, key, minio.StatObjectOptions{})
return err == nil
}
// PutBrowseAsset stores a binary asset (e.g. a cover image) in the browse bucket.
// contentType should be the MIME type, e.g. "image/jpeg".
func (m *MinioClient) PutBrowseAsset(ctx context.Context, key string, data []byte, contentType string) error {
_, err := m.c.PutObject(ctx, m.cfg.BucketBrowse, key,
bytes.NewReader(data), int64(len(data)),
minio.PutObjectOptions{ContentType: contentType})
if err != nil {
return fmt.Errorf("minio: put browse asset %s: %w", key, err)
}
return nil
}
// GetBrowseAsset retrieves a binary asset from the browse bucket.
// Returns (nil, false, nil) when the object does not exist.
func (m *MinioClient) GetBrowseAsset(ctx context.Context, key string) ([]byte, string, bool, error) {
obj, err := m.c.GetObject(ctx, m.cfg.BucketBrowse, key, minio.GetObjectOptions{})
if err != nil {
return nil, "", false, fmt.Errorf("minio: get browse asset %s: %w", key, err)
}
defer obj.Close()
info, statErr := obj.Stat()
if statErr != nil {
return nil, "", false, nil // not found
}
data, err := io.ReadAll(obj)
if err != nil {
return nil, "", false, fmt.Errorf("minio: read browse asset %s: %w", key, err)
}
return data, info.ContentType, true, nil
}
// ─── helpers ──────────────────────────────────────────────────────────────────
// sanitiseVoice converts a voice name to a filename-safe string.
func sanitiseVoice(voice string) string {
return strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '_' || r == '-' {
return r
}
return '_'
}, voice)
}