package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"skraak_mcp/db"
"skraak_mcp/utils"
)
type ImportFileInput struct {
FilePath string `json:"file_path" jsonschema:"required,Absolute path to WAV file"`
DatasetID string `json:"dataset_id" jsonschema:"required,Dataset ID (12 characters)"`
LocationID string `json:"location_id" jsonschema:"required,Location ID (12 characters)"`
ClusterID string `json:"cluster_id" jsonschema:"required,Cluster ID (12 characters)"`
}
type ImportFileOutput struct {
FileID string `json:"file_id" jsonschema:"Generated 21-character nanoid"`
FileName string `json:"file_name" jsonschema:"Base filename"`
Hash string `json:"hash" jsonschema:"XXH64 hash (16-character hex)"`
Duration float64 `json:"duration_seconds" jsonschema:"File duration in seconds"`
SampleRate int `json:"sample_rate" jsonschema:"Sample rate in Hz"`
TimestampLocal time.Time `json:"timestamp_local" jsonschema:"Local timestamp"`
IsAudioMoth bool `json:"is_audiomoth" jsonschema:"AudioMoth detection"`
IsDuplicate bool `json:"is_duplicate" jsonschema:"Skipped as duplicate"`
ProcessingTime string `json:"processing_time" jsonschema:"Duration string"`
Error *string `json:"error,omitempty" jsonschema:"Error if failed"`
}
type locationData struct {
Latitude float64
Longitude float64
TimezoneID string
}
type fileData struct {
FileName string
Hash string
Duration float64
SampleRate int
TimestampLocal time.Time
IsAudioMoth bool
MothData *utils.AudioMothData
AstroData utils.AstronomicalData
}
func ImportFile(
ctx context.Context,
req *mcp.CallToolRequest,
input ImportFileInput,
) (*mcp.CallToolResult, ImportFileOutput, error) {
startTime := time.Now()
var output ImportFileOutput
_, err := validateFilePath(input.FilePath)
if err != nil {
return nil, output, fmt.Errorf("file validation failed: %w", err)
}
output.FileName = filepath.Base(input.FilePath)
if err := validateImportInput(ImportAudioFilesInput{
DatasetID: input.DatasetID,
LocationID: input.LocationID,
ClusterID: input.ClusterID,
FolderPath: filepath.Dir(input.FilePath), }, dbPath); err != nil {
return nil, output, fmt.Errorf("hierarchy validation failed: %w", err)
}
locationData, err := getLocationData(dbPath, input.LocationID)
if err != nil {
return nil, output, fmt.Errorf("failed to get location data: %w", err)
}
fileData, err := processFile(input.FilePath, locationData)
if err != nil {
errMsg := err.Error()
output.Error = &errMsg
output.ProcessingTime = time.Since(startTime).String()
return nil, output, fmt.Errorf("file processing failed: %w", err)
}
output.FileName = fileData.FileName
output.Hash = fileData.Hash
output.Duration = fileData.Duration
output.SampleRate = fileData.SampleRate
output.TimestampLocal = fileData.TimestampLocal
output.IsAudioMoth = fileData.IsAudioMoth
if err := ensureClusterPath(dbPath, input.ClusterID, filepath.Dir(input.FilePath)); err != nil {
return nil, output, fmt.Errorf("failed to set cluster path: %w", err)
}
fileID, isDuplicate, err := insertFileIntoDB(
dbPath,
fileData,
input.DatasetID,
input.ClusterID,
input.LocationID,
)
if err != nil {
errMsg := err.Error()
output.Error = &errMsg
output.ProcessingTime = time.Since(startTime).String()
return nil, output, fmt.Errorf("database insertion failed: %w", err)
}
output.FileID = fileID
output.IsDuplicate = isDuplicate
output.ProcessingTime = time.Since(startTime).String()
return &mcp.CallToolResult{}, output, nil
}
func validateFilePath(filePath string) (os.FileInfo, error) {
info, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("file does not exist: %s", filePath)
}
return nil, fmt.Errorf("cannot access file: %w", err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("path is not a regular file: %s", filePath)
}
ext := strings.ToLower(filepath.Ext(filePath))
if ext != ".wav" {
return nil, fmt.Errorf("file must be a WAV file (got extension: %s)", ext)
}
if info.Size() == 0 {
return nil, fmt.Errorf("file is empty: %s", filePath)
}
return info, nil
}
func processFile(filePath string, location *locationData) (*fileData, error) {
result, err := utils.ProcessSingleFile(filePath, location.Latitude, location.Longitude, location.TimezoneID, true)
if err != nil {
return nil, err
}
return &fileData{
FileName: result.FileName,
Hash: result.Hash,
Duration: result.Duration,
SampleRate: result.SampleRate,
TimestampLocal: result.TimestampLocal,
IsAudioMoth: result.IsAudioMoth,
MothData: result.MothData,
AstroData: result.AstroData,
}, nil
}
func insertFileIntoDB(
dbPath string,
fileData *fileData,
datasetID, clusterID, locationID string,
) (string, bool, error) {
database, err := db.OpenWriteableDB(dbPath)
if err != nil {
return "", false, fmt.Errorf("failed to open database: %w", err)
}
defer database.Close()
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
return "", false, fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
existingID, isDup, err := utils.CheckDuplicateHash(tx, fileData.Hash)
if err != nil {
return "", false, err
}
if isDup {
return existingID, true, nil
}
fileID, err := utils.GenerateLongID()
if err != nil {
return "", false, fmt.Errorf("ID generation failed: %w", err)
}
_, err = tx.ExecContext(ctx, `
INSERT INTO file (
id, file_name, xxh64_hash, location_id, timestamp_local,
cluster_id, duration, sample_rate, maybe_solar_night, maybe_civil_night,
moon_phase, created_at, last_modified, active
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now(), true)
`,
fileID, fileData.FileName, fileData.Hash, locationID,
fileData.TimestampLocal, clusterID, fileData.Duration, fileData.SampleRate,
fileData.AstroData.SolarNight, fileData.AstroData.CivilNight, fileData.AstroData.MoonPhase,
)
if err != nil {
return "", false, fmt.Errorf("file insert failed: %w", err)
}
_, err = tx.ExecContext(ctx, `
INSERT INTO file_dataset (file_id, dataset_id, created_at, last_modified)
VALUES (?, ?, now(), now())
`, fileID, datasetID)
if err != nil {
return "", false, fmt.Errorf("file_dataset insert failed: %w", err)
}
if fileData.IsAudioMoth && fileData.MothData != nil {
_, err = tx.ExecContext(ctx, `
INSERT INTO moth_metadata (
file_id, timestamp, recorder_id, gain, battery_v, temp_c,
created_at, last_modified, active
) VALUES (?, ?, ?, ?, ?, ?, now(), now(), true)
`,
fileID,
fileData.MothData.Timestamp,
&fileData.MothData.RecorderID,
&fileData.MothData.Gain,
&fileData.MothData.BatteryV,
&fileData.MothData.TempC,
)
if err != nil {
return "", false, fmt.Errorf("moth_metadata insert failed: %w", err)
}
}
if err = tx.Commit(); err != nil {
return "", false, fmt.Errorf("transaction commit failed: %w", err)
}
return fileID, false, nil
}
func getLocationData(dbPath, locationID string) (*locationData, error) {
database, err := db.OpenReadOnlyDB(dbPath)
if err != nil {
return nil, err
}
defer database.Close()
var loc locationData
err = database.QueryRow(
"SELECT latitude, longitude, timezone_id FROM location WHERE id = ?",
locationID,
).Scan(&loc.Latitude, &loc.Longitude, &loc.TimezoneID)
if err != nil {
return nil, fmt.Errorf("failed to query location data: %w", err)
}
return &loc, nil
}
func ensureClusterPath(dbPath, clusterID, folderPath string) error {
database, err := db.OpenWriteableDB(dbPath)
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
defer database.Close()
return utils.EnsureClusterPath(database, clusterID, folderPath)
}