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:/* 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, }) }