Files
meta-core/internal/api/metadata.go
T
2026-10-19 20:51:00 +00:00

556 lines
16 KiB
Go

package api
import (
"encoding/json"
"net/http"
"strconv"
"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"`
// Filters is the structured form: AND across entries, OR within an
// entry's Values. Narrowed in SQL through the field index alongside the
// free-text Query, then verified per record (see serveIndexedSearch), so an
// id-anchored lookup costs an index probe rather than a read per record.
//
// Property/PropertyValue is the single-filter legacy shape and folds into
// this list (see normalizedFilters) with Exact=false, preserving its
// substring semantics byte-for-byte.
Filters []MetadataFilter `json:"filters,omitempty"`
}
// MetadataFilter is one structured constraint on a record.
//
// A record satisfies it when ANY of Values matches, and a search result must
// satisfy EVERY filter (OR within, AND across). An entry with no usable value
// constrains nothing — same rule the gateway's `genres_filter_matches` uses, so
// a caller that lowers an empty filter doesn't accidentally empty the result.
type MetadataFilter struct {
// Property is the field name, WITHOUT a key-set suffix: `genres`, not
// `genres/Action`. Both storage shapes are probed — see recordMatchesFilter.
Property string `json:"property"`
// Values are OR'd. Compared case-insensitively.
Values []string `json:"values,omitempty"`
// Exact selects CSV-exact comparison (the semantics meta-gateway's
// `any_csv_value_matches` applies at its own Layer-B re-filter) over
// substring containment. Callers doing identity lookups want this: with a
// result limit, a substring match on `tmdbid=1405` also admits `21405` and
// can crowd the real matches out of the page.
Exact bool `json:"exact,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
}
// Same resolution as the PUT below: callers hold a CID, not the root.
hashID = s.storage.ResolveRoot(hashID)
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
}
// The {hashId} in the URL is whatever CID the caller knows the file by —
// the gateway's auto-store keys this PUT by the hash it just computed
// (dispatch.rs → store_metadata_only), which for a usenet→IPFS fetch is
// the dag-pb root, not the nzb-release cid the record was minted under.
// Without resolving, the write lands on file:<thatCid>/* and forks a
// second root for a file that already has one: a full duplicate record,
// cross-linked and separately indexed, which every downstream ingest then
// sees as two files. Unknown CIDs pass through unchanged, so a genuinely
// new gateway record still mints its root at the hash.
hashID = s.storage.ResolveRoot(hashID)
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
}
// meta-gateway retracts a dropped hit by whichever CID it holds
// (meta_core.rs `delete`, which documents this resolution as a
// precondition). Without it, deleting by a non-root CID is a silent
// no-op and the retracted record lingers.
hashID = s.storage.ResolveRoot(hashID)
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
}
// Everything else — free text, structured filters, both, or neither
// (which returns the first `limit`) — is served from the store's derived
// search indexes (search_index.go → storage.Search).
//
// There is deliberately no second path. The property branch used to fall
// through to a `GetAllHashIDs` + per-record `GetMetadataFlat` scan, which
// is what made an id-anchored lookup 3.63s over ~20k records (vs 10ms for
// the free-text branch on the same corpus) and kept meta-core out of the
// gateway's anchored search path entirely. The index already holds every
// field of every record, so that scan was re-reading data it had in hand.
s.serveIndexedSearch(w, req)
}
// 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,
})
}