move feature from meta-sort to meta-core

This commit is contained in:
Pierre
2026-01-25 22:01:25 +01:00
parent 768942d28b
commit 7d9d502912
69 changed files with 12831 additions and 13 deletions
+186
View File
@@ -0,0 +1,186 @@
package api
import (
"net/http"
"net/url"
"strconv"
"strings"
"github.com/gorilla/mux"
)
// KVInfoResponse is the response for GET /api/kv/info
type KVInfoResponse struct {
Prefix string `json:"prefix"`
FileCount int `json:"fileCount"`
KeyCount int `json:"keyCount"`
TotalSize int64 `json:"totalSize"`
MemoryUsage string `json:"memoryUsage"`
}
// KVKeysResponse is the response for GET /api/kv/keys
type KVKeysResponse struct {
Keys []string `json:"keys"`
Cursor string `json:"cursor"`
HasMore bool `json:"hasMore"`
}
// KVKeyValueResponse is the response for GET /api/kv/key/{key}
type KVKeyValueResponse struct {
Key string `json:"key"`
Type string `json:"type"`
Value map[string]string `json:"value"`
}
// handleKVInfo handles GET /api/kv/info
func (s *Server) handleKVInfo(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
// Get all hash IDs to count files
hashIDs, err := s.storage.GetAllHashIDs()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Get memory usage
memoryUsage, err := s.storage.GetMemoryInfo()
if err != nil {
memoryUsage = "N/A"
}
// Calculate total size from sizeByte metadata (if available)
var totalSize int64
for _, hashID := range hashIDs {
sizeStr, err := s.storage.GetProperty(hashID, "sizeByte")
if err == nil && sizeStr != "" {
if size, err := strconv.ParseInt(sizeStr, 10, 64); err == nil {
totalSize += size
}
}
}
response := KVInfoResponse{
Prefix: "file:",
FileCount: len(hashIDs),
KeyCount: len(hashIDs),
TotalSize: totalSize,
MemoryUsage: memoryUsage,
}
writeJSON(w, http.StatusOK, response)
}
// handleKVKeys handles GET /api/kv/keys
func (s *Server) handleKVKeys(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
// Parse query parameters
cursorStr := r.URL.Query().Get("cursor")
countStr := r.URL.Query().Get("count")
cursor := 0
if cursorStr != "" {
if c, err := strconv.Atoi(cursorStr); err == nil {
cursor = c
}
}
count := 50
if countStr != "" {
if c, err := strconv.Atoi(countStr); err == nil && c > 0 {
count = c
}
}
// Get all hash IDs
allHashIDs, err := s.storage.GetAllHashIDs()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Simple offset-based pagination
var keys []string
end := cursor + count
if end > len(allHashIDs) {
end = len(allHashIDs)
}
if cursor < len(allHashIDs) {
for _, id := range allHashIDs[cursor:end] {
keys = append(keys, "file:"+id)
}
}
nextCursor := cursor + len(keys)
hasMore := nextCursor < len(allHashIDs)
response := KVKeysResponse{
Keys: keys,
Cursor: strconv.Itoa(nextCursor),
HasMore: hasMore,
}
if !hasMore {
response.Cursor = "0"
}
writeJSON(w, http.StatusOK, response)
}
// handleKVGetKey handles GET /api/kv/key/{key}
func (s *Server) handleKVGetKey(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
key := vars["key"]
if key == "" {
writeError(w, http.StatusBadRequest, "key is required")
return
}
// URL decode the key (in case it contains encoded characters)
decodedKey, err := url.PathUnescape(key)
if err != nil {
decodedKey = key
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
// Extract hash ID from key (format: file:{hashId})
if !strings.HasPrefix(decodedKey, "file:") {
writeError(w, http.StatusBadRequest, "invalid key format, expected: file:{hashId}")
return
}
hashID := strings.TrimPrefix(decodedKey, "file:")
// Get metadata for this hash ID
metadata, err := s.storage.GetMetadataFlat(hashID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if metadata == nil {
writeError(w, http.StatusNotFound, "key not found")
return
}
response := KVKeyValueResponse{
Key: decodedKey,
Type: "hash",
Value: metadata,
}
writeJSON(w, http.StatusOK, response)
}
+568
View File
@@ -0,0 +1,568 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/gorilla/mux"
)
// HashIDsResponse is the response for GET /api/metadata/hash-ids
type HashIDsResponse struct {
HashIds []string `json:"hashIds"`
Count int `json:"count"`
}
// MetadataListItem represents a file in the metadata list
type MetadataListItem struct {
HashID string `json:"hashId"`
Title string `json:"title,omitempty"`
FileName string `json:"fileName,omitempty"`
FilePath string `json:"filePath,omitempty"`
Type string `json:"type,omitempty"`
Year string `json:"year,omitempty"`
}
// MetadataListResponse is the response for GET /api/metadata/list
type MetadataListResponse struct {
Items []MetadataListItem `json:"items"`
Count int `json:"count"`
Total int `json:"total"`
}
// MetadataSearchRequest is the request body for POST /api/metadata/search
type MetadataSearchRequest struct {
Query string `json:"query,omitempty"`
HashID string `json:"hashId,omitempty"`
Property string `json:"property,omitempty"`
PropertyValue string `json:"propertyValue,omitempty"`
Limit int `json:"limit,omitempty"`
}
// MetadataSearchResult is a single search result
type MetadataSearchResult struct {
HashID string `json:"hashId"`
Metadata map[string]string `json:"metadata"`
}
// MetadataSearchResponse is the response for POST /api/metadata/search
type MetadataSearchResponse struct {
Results []MetadataSearchResult `json:"results"`
Count int `json:"count"`
Total int `json:"total"`
}
// MetadataBatchUpdateRequest is the request body for POST /api/metadata/batch
type MetadataBatchUpdateRequest struct {
Updates []MetadataBatchItem `json:"updates"`
}
// MetadataBatchItem is a single item in a batch update
type MetadataBatchItem struct {
HashID string `json:"hashId"`
Metadata map[string]string `json:"metadata,omitempty"`
Properties map[string]string `json:"properties,omitempty"`
}
// MetadataBatchResult is a single result from a batch update
type MetadataBatchResult struct {
HashID string `json:"hashId"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
// MetadataBatchResponse is the response for POST /api/metadata/batch
type MetadataBatchResponse struct {
Status string `json:"status"`
Total int `json:"total"`
Success int `json:"success"`
Errors int `json:"errors"`
Results []MetadataBatchResult `json:"results"`
}
// PropertyUpdateRequest is the request body for PUT /api/metadata/{hashId}/property
type PropertyUpdateRequest struct {
Property string `json:"property"`
Value string `json:"value"`
}
// handleGetHashIds handles GET /api/metadata/hash-ids
func (s *Server) handleGetHashIds(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
hashIDs, err := s.storage.GetAllHashIDs()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
response := HashIDsResponse{
HashIds: hashIDs,
Count: len(hashIDs),
}
writeJSON(w, http.StatusOK, response)
}
// handleListMetadata handles GET /api/metadata/list
func (s *Server) handleListMetadata(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
// Parse pagination parameters
offsetStr := r.URL.Query().Get("offset")
limitStr := r.URL.Query().Get("limit")
offset := 0
if offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil {
offset = o
}
}
limit := 100
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
}
// Get all hash IDs
allHashIDs, err := s.storage.GetAllHashIDs()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
total := len(allHashIDs)
// Apply pagination
end := offset + limit
if end > total {
end = total
}
var items []MetadataListItem
if offset < total {
for _, hashID := range allHashIDs[offset:end] {
metadata, err := s.storage.GetMetadataFlat(hashID)
if err != nil || metadata == nil {
continue
}
item := MetadataListItem{
HashID: hashID,
Title: metadata["title"],
FileName: metadata["fileName"],
FilePath: metadata["filePath"],
Type: metadata["type"],
Year: metadata["year"],
}
items = append(items, item)
}
}
response := MetadataListResponse{
Items: items,
Count: len(items),
Total: total,
}
writeJSON(w, http.StatusOK, response)
}
// handleGetMetadataByHashId handles GET /api/metadata/{hashId}
func (s *Server) handleGetMetadataByHashId(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hashId"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash ID is required")
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
metadata, err := s.storage.GetMetadataFlat(hashID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if metadata == nil {
writeError(w, http.StatusNotFound, "metadata not found")
return
}
response := MetadataResponse{
HashID: hashID,
Metadata: metadata,
}
writeJSON(w, http.StatusOK, response)
}
// handleUpdateMetadataByHashId handles PUT /api/metadata/{hashId}
func (s *Server) handleUpdateMetadataByHashId(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hashId"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash ID is required")
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
var metadata map[string]string
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
// Exclude internal processing status field
delete(metadata, "processingStatus")
if err := s.storage.SetMetadataFlat(hashID, metadata); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"hashId": hashID,
"message": "Metadata updated successfully",
})
}
// handleDeleteMetadataByHashId handles DELETE /api/metadata/{hashId}
func (s *Server) handleDeleteMetadataByHashId(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hashId"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash ID is required")
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
deletedCount, err := s.storage.DeleteMetadata(hashID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"hashId": hashID,
"deletedKeys": deletedCount,
})
}
// handleMetadataGetProperty handles GET /api/metadata/{hashId}/property?property=X
func (s *Server) handleMetadataGetProperty(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hashId"]
property := r.URL.Query().Get("property")
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash ID is required")
return
}
if property == "" {
writeError(w, http.StatusBadRequest, "property parameter is required")
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
value, err := s.storage.GetProperty(hashID, property)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if value == "" {
writeError(w, http.StatusNotFound, "property not found")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"hashId": hashID,
"property": property,
"value": value,
})
}
// handleMetadataUpdateProperty handles PUT /api/metadata/{hashId}/property
func (s *Server) handleMetadataUpdateProperty(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hashId"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash ID is required")
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
var req PropertyUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.Property == "" {
writeError(w, http.StatusBadRequest, "property is required")
return
}
if err := s.storage.SetProperty(hashID, req.Property, req.Value); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"hashId": hashID,
"property": req.Property,
"value": req.Value,
"message": "Property updated successfully",
})
}
// handleSearchMetadata handles POST /api/metadata/search
func (s *Server) handleSearchMetadata(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
var req MetadataSearchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.Limit == 0 {
req.Limit = 100
}
// If hashId is provided, do exact match
if req.HashID != "" {
metadata, err := s.storage.GetMetadataFlat(req.HashID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if metadata != nil {
writeJSON(w, http.StatusOK, MetadataSearchResponse{
Results: []MetadataSearchResult{{HashID: req.HashID, Metadata: metadata}},
Count: 1,
Total: 1,
})
return
}
writeJSON(w, http.StatusOK, MetadataSearchResponse{
Results: []MetadataSearchResult{},
Count: 0,
Total: 0,
})
return
}
// Get all hash IDs
allHashIDs, err := s.storage.GetAllHashIDs()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
var results []MetadataSearchResult
total := len(allHashIDs)
// Search fields for query
searchFields := []string{"title", "fileName", "originaltitle", "showtitle", "filePath"}
for _, hashID := range allHashIDs {
if len(results) >= req.Limit {
break
}
metadata, err := s.storage.GetMetadataFlat(hashID)
if err != nil || metadata == nil {
continue
}
matches := false
// Property search
if req.Property != "" && req.PropertyValue != "" {
value, ok := metadata[req.Property]
if ok && strings.Contains(strings.ToLower(value), strings.ToLower(req.PropertyValue)) {
matches = true
}
}
// General query search
if req.Query != "" && req.Property == "" {
queryLower := strings.ToLower(req.Query)
// Check if query matches hashId
if strings.Contains(strings.ToLower(hashID), queryLower) {
matches = true
}
// Check search fields
if !matches {
for _, field := range searchFields {
if value, ok := metadata[field]; ok {
if strings.Contains(strings.ToLower(value), queryLower) {
matches = true
break
}
}
}
}
}
// If no query or property filter, return all
if req.Query == "" && req.Property == "" {
matches = true
}
if matches {
results = append(results, MetadataSearchResult{
HashID: hashID,
Metadata: metadata,
})
}
}
writeJSON(w, http.StatusOK, MetadataSearchResponse{
Results: results,
Count: len(results),
Total: total,
})
}
// handleBatchUpdate handles POST /api/metadata/batch
func (s *Server) handleBatchUpdate(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
var req MetadataBatchUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if len(req.Updates) == 0 {
writeError(w, http.StatusBadRequest, "updates array is required")
return
}
var results []MetadataBatchResult
successCount := 0
errorCount := 0
for _, update := range req.Updates {
if update.HashID == "" {
results = append(results, MetadataBatchResult{
HashID: "unknown",
Status: "error",
Error: "Missing hash ID",
})
errorCount++
continue
}
var updateErr error
// Update complete metadata
if update.Metadata != nil {
// Exclude internal processing status
delete(update.Metadata, "processingStatus")
updateErr = s.storage.SetMetadataFlat(update.HashID, update.Metadata)
}
// Update individual properties
if updateErr == nil && update.Properties != nil {
for property, value := range update.Properties {
if err := s.storage.SetProperty(update.HashID, property, value); err != nil {
updateErr = err
break
}
}
}
if updateErr != nil {
results = append(results, MetadataBatchResult{
HashID: update.HashID,
Status: "error",
Error: updateErr.Error(),
})
errorCount++
} else {
results = append(results, MetadataBatchResult{
HashID: update.HashID,
Status: "ok",
})
successCount++
}
}
writeJSON(w, http.StatusOK, MetadataBatchResponse{
Status: "completed",
Total: len(results),
Success: successCount,
Errors: errorCount,
Results: results,
})
}
// handleClearMetadata handles POST /api/metadata/clear
func (s *Server) handleClearMetadata(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
deletedCount, err := s.storage.ClearAllMetadata()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"message": "Metadata cleared",
"deletedCount": deletedCount,
})
}
+80 -6
View File
@@ -11,17 +11,24 @@ import (
"github.com/metazla/meta-core/internal/config"
"github.com/metazla/meta-core/internal/discovery"
"github.com/metazla/meta-core/internal/leader"
"github.com/metazla/meta-core/internal/mounts"
"github.com/metazla/meta-core/internal/storage"
"github.com/metazla/meta-core/internal/watcher"
)
// Server is the HTTP API server for meta-core
type Server struct {
config *config.Config
election *leader.Election
discovery *discovery.Service
storage *storage.Client
router *mux.Router
server *http.Server
config *config.Config
election *leader.Election
discovery *discovery.Service
storage *storage.Client
mountsManager *mounts.Manager
mountsHandlers *mounts.Handlers
watcherDispatcher *watcher.Dispatcher
fileWatcher *watcher.Watcher
watcherHandlers *watcher.Handlers
router *mux.Router
server *http.Server
}
// NewServer creates a new API server
@@ -39,6 +46,27 @@ func NewServer(
router: mux.NewRouter(),
}
// Initialize mounts manager
mountsManager, err := mounts.NewManager(cfg)
if err != nil {
log.Printf("[API] Warning: failed to initialize mounts manager: %v", err)
} else {
s.mountsManager = mountsManager
s.mountsHandlers = mounts.NewHandlers(mountsManager)
}
// Initialize file watcher (if enabled)
if cfg.EnableFileWatcher && len(cfg.WatchFolderList) > 0 {
s.watcherDispatcher = watcher.NewDispatcher()
fileWatcher, err := watcher.NewWatcher(cfg, s.watcherDispatcher)
if err != nil {
log.Printf("[API] Warning: failed to initialize file watcher: %v", err)
} else {
s.fileWatcher = fileWatcher
s.watcherHandlers = watcher.NewHandlers(fileWatcher, s.watcherDispatcher)
}
}
s.setupRoutes()
return s
}
@@ -51,6 +79,23 @@ func (s *Server) setupRoutes() {
s.router.HandleFunc("/leader", s.handleLeader).Methods("GET")
s.router.HandleFunc("/role", s.handleRole).Methods("GET")
// Metadata Editor API routes (must be before /meta/{hash} routes)
s.router.HandleFunc("/api/metadata/hash-ids", s.handleGetHashIds).Methods("GET")
s.router.HandleFunc("/api/metadata/list", s.handleListMetadata).Methods("GET")
s.router.HandleFunc("/api/metadata/search", s.handleSearchMetadata).Methods("POST")
s.router.HandleFunc("/api/metadata/batch", s.handleBatchUpdate).Methods("POST")
s.router.HandleFunc("/api/metadata/clear", s.handleClearMetadata).Methods("POST")
s.router.HandleFunc("/api/metadata/{hashId}/property", s.handleMetadataGetProperty).Methods("GET")
s.router.HandleFunc("/api/metadata/{hashId}/property", s.handleMetadataUpdateProperty).Methods("PUT")
s.router.HandleFunc("/api/metadata/{hashId}", s.handleGetMetadataByHashId).Methods("GET")
s.router.HandleFunc("/api/metadata/{hashId}", s.handleUpdateMetadataByHashId).Methods("PUT")
s.router.HandleFunc("/api/metadata/{hashId}", s.handleDeleteMetadataByHashId).Methods("DELETE")
// KV Browser API routes
s.router.HandleFunc("/api/kv/info", s.handleKVInfo).Methods("GET")
s.router.HandleFunc("/api/kv/keys", s.handleKVKeys).Methods("GET")
s.router.HandleFunc("/api/kv/key/{key:.*}", s.handleKVGetKey).Methods("GET")
// Metadata operations - base endpoints
s.router.HandleFunc("/meta/{hash}", s.handleGetMeta).Methods("GET")
s.router.HandleFunc("/meta/{hash}", s.handlePutMeta).Methods("PUT")
@@ -78,6 +123,21 @@ func (s *Server) setupRoutes() {
s.router.HandleFunc("/services", s.handleListServices).Methods("GET")
s.router.HandleFunc("/services/{name}", s.handleGetService).Methods("GET")
// Mount management routes (if manager initialized)
if s.mountsHandlers != nil {
s.mountsHandlers.RegisterRoutes(s.router)
log.Println("[API] Mount management routes registered at /api/mounts/*")
}
log.Println("[API] Metadata Editor routes registered at /api/metadata/*")
log.Println("[API] KV Browser routes registered at /api/kv/*")
// File watcher routes (if watcher initialized)
if s.watcherHandlers != nil {
s.watcherHandlers.RegisterRoutes(s.router)
log.Println("[API] File watcher routes registered at /api/events/*, /api/scan/*")
}
// Add middleware
s.router.Use(loggingMiddleware)
s.router.Use(corsMiddleware)
@@ -103,11 +163,25 @@ func (s *Server) Start() error {
}
}()
// Start file watcher (if initialized)
if s.fileWatcher != nil {
if err := s.fileWatcher.Start(); err != nil {
log.Printf("[API] Warning: failed to start file watcher: %v", err)
}
}
return nil
}
// Stop gracefully stops the HTTP server
func (s *Server) Stop() error {
// Stop file watcher
if s.fileWatcher != nil {
if err := s.fileWatcher.Stop(); err != nil {
log.Printf("[API] Warning: failed to stop file watcher: %v", err)
}
}
if s.server == nil {
return nil
}