package storage import ( "bytes" "context" "fmt" "io" "strings" "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" AccessKey string SecretKey string UseSSL bool BucketChapters string // e.g. "libnovel-chapters" BucketAudio string // e.g. "libnovel-audio" } // MinioClient wraps a minio.Client and exposes object operations for // chapters and audio files. type MinioClient struct { c *minio.Client 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) } mc := &MinioClient{c: c, cfg: cfg} for _, bucket := range []string{cfg.BucketChapters, cfg.BucketAudio} { 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 } // ─── 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) }