Files
MeBox/internal/repository/opensearch.go
T
2026-06-15 18:16:34 +08:00

213 lines
5.7 KiB
Go

package repository
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
type OpenSearchMediaBackend struct {
baseURL string
index string
username string
password string
client *http.Client
}
func NewOpenSearchMediaBackend(cfg config.SearchConfig) *OpenSearchMediaBackend {
if strings.TrimSpace(cfg.Backend) != "opensearch" || strings.TrimSpace(cfg.OpenSearchURL) == "" {
return nil
}
index := strings.TrimSpace(cfg.Index)
if index == "" {
index = "mediastation_media"
}
return &OpenSearchMediaBackend{
baseURL: strings.TrimRight(strings.TrimSpace(cfg.OpenSearchURL), "/"),
index: index,
username: strings.TrimSpace(cfg.Username),
password: cfg.Password,
client: &http.Client{Timeout: 4 * time.Second},
}
}
func (b *OpenSearchMediaBackend) SearchMediaIDs(ctx context.Context, query string, offset, limit int, filter MediaQueryFilter) ([]string, int64, error) {
if b == nil || b.client == nil || b.baseURL == "" || b.index == "" {
return nil, 0, fmt.Errorf("opensearch backend not configured")
}
if limit <= 0 {
limit = 50
}
must := []any{
map[string]any{
"multi_match": map[string]any{
"query": query,
"fields": []string{"title^4", "original_name^3", "genres^2", "path"},
"type": "best_fields",
"operator": "and",
"fuzziness": "AUTO",
},
},
}
filters := []any{
map[string]any{"term": map[string]any{"deleted": false}},
}
if !filter.IncludeNSFW {
filters = append(filters, map[string]any{"term": map[string]any{"nsfw": false}})
}
if len(filter.AllowedLibraryIDs) > 0 {
filters = append(filters, map[string]any{"terms": map[string]any{"library_id": filter.AllowedLibraryIDs}})
}
if len(filter.HiddenLibraryIDs) > 0 {
filters = append(filters, map[string]any{"bool": map[string]any{
"must_not": []any{map[string]any{"terms": map[string]any{"library_id": filter.HiddenLibraryIDs}}},
}})
}
body := map[string]any{
"from": offset,
"size": limit,
"_source": []string{
"id",
},
"query": map[string]any{
"bool": map[string]any{
"must": must,
"filter": filters,
},
},
}
var resp struct {
Hits struct {
Total any `json:"total"`
Hits []struct {
ID string `json:"_id"`
Source struct {
ID string `json:"id"`
} `json:"_source"`
} `json:"hits"`
} `json:"hits"`
}
if err := b.doJSON(ctx, http.MethodPost, "/"+url.PathEscape(b.index)+"/_search", body, &resp); err != nil {
return nil, 0, err
}
ids := make([]string, 0, len(resp.Hits.Hits))
for _, hit := range resp.Hits.Hits {
id := strings.TrimSpace(hit.Source.ID)
if id == "" {
id = strings.TrimSpace(hit.ID)
}
if id != "" {
ids = append(ids, id)
}
}
return ids, openSearchTotal(resp.Hits.Total), nil
}
func (b *OpenSearchMediaBackend) EnsureIndex(ctx context.Context) error {
if err := b.do(ctx, http.MethodHead, "/"+url.PathEscape(b.index), nil, "", nil); err == nil {
return nil
}
mapping := map[string]any{
"mappings": map[string]any{
"properties": map[string]any{
"id": map[string]any{"type": "keyword"},
"library_id": map[string]any{"type": "keyword"},
"title": map[string]any{"type": "text"},
"original_name": map[string]any{"type": "text"},
"path": map[string]any{"type": "text"},
"genres": map[string]any{"type": "text"},
"nsfw": map[string]any{"type": "boolean"},
"deleted": map[string]any{"type": "boolean"},
"created_at": map[string]any{"type": "date"},
},
},
}
return b.doJSON(ctx, http.MethodPut, "/"+url.PathEscape(b.index), mapping, nil)
}
func (b *OpenSearchMediaBackend) IndexMedia(ctx context.Context, rows []model.Media) error {
if len(rows) == 0 {
return nil
}
var bulk bytes.Buffer
enc := json.NewEncoder(&bulk)
for _, row := range rows {
if err := enc.Encode(map[string]any{"index": map[string]any{"_index": b.index, "_id": row.ID}}); err != nil {
return err
}
if err := enc.Encode(map[string]any{
"id": row.ID,
"library_id": row.LibraryID,
"title": row.Title,
"original_name": row.OriginalName,
"path": row.Path,
"genres": row.Genres,
"nsfw": row.NSFW,
"deleted": row.DeletedAt.Valid,
"created_at": row.CreatedAt,
}); err != nil {
return err
}
}
return b.do(ctx, http.MethodPost, "/_bulk", &bulk, "application/x-ndjson", nil)
}
func (b *OpenSearchMediaBackend) doJSON(ctx context.Context, method, path string, body any, out any) error {
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(raw)
}
return b.do(ctx, method, path, reader, "application/json", out)
}
func (b *OpenSearchMediaBackend) do(ctx context.Context, method, path string, body io.Reader, contentType string, out any) error {
req, err := http.NewRequestWithContext(ctx, method, b.baseURL+path, body)
if err != nil {
return err
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if b.username != "" {
req.SetBasicAuth(b.username, b.password)
}
resp, err := b.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("opensearch %s %s returned %d", method, path, resp.StatusCode)
}
if out == nil {
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
func openSearchTotal(value any) int64 {
switch v := value.(type) {
case float64:
return int64(v)
case map[string]any:
if n, ok := v["value"].(float64); ok {
return int64(n)
}
}
return 0
}