204 lines
7.2 KiB
Go
204 lines
7.2 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"
|
|
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
|
|
}
|
|
|
|
// ─── Presigned URLs ───────────────────────────────────────────────────────────
|
|
|
|
// PresignChapter returns a presigned GET URL for a chapter object, valid for
|
|
// the given duration. The URL is signed with the MinIO credentials and can be
|
|
// fetched directly by the browser without authentication.
|
|
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.
|
|
func (m *MinioClient) PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error) {
|
|
u, err := m.c.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
|
|
}
|
|
|
|
// ─── 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)
|
|
}
|