C3YEXRHPVZVGUJDZEUPDYWC5JZYBCSSC2ZHORSYSER5TICPX76WAC package utilsimport ("testing""time")func TestGenerateFileID(t *testing.T) {t.Run("generates 21-character ID", func(t *testing.T) {id, err := GenerateFileID()if err != nil {t.Fatalf("unexpected error: %v", err)}if len(id) != 21 {t.Errorf("expected length 21, got %d: %q", len(id), id)}})t.Run("uses only valid alphabet characters", func(t *testing.T) {id, err := GenerateFileID()if err != nil {t.Fatalf("unexpected error: %v", err)}for _, c := range id {if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) {t.Errorf("invalid character %q in ID %q", string(c), id)}}})t.Run("generates unique IDs", func(t *testing.T) {seen := make(map[string]bool)for i := 0; i < 100; i++ {id, err := GenerateFileID()if err != nil {t.Fatalf("unexpected error: %v", err)}if seen[id] {t.Errorf("duplicate ID generated: %q", id)}seen[id] = true}})}func TestResolveTimestamp(t *testing.T) {t.Run("resolves AudioMoth timestamp", func(t *testing.T) {meta := &WAVMetadata{Comment: "Recorded at 21:00:00 24/02/2025 (UTC+13) by AudioMoth 248AB50153AB0549 at medium gain while battery was 4.3V and temperature was 15.8C.",Artist: "AudioMoth",}result, err := ResolveTimestamp(meta, "20250224_210000.wav", "Pacific/Auckland", false)if err != nil {t.Fatalf("unexpected error: %v", err)}if !result.IsAudioMoth {t.Error("expected IsAudioMoth to be true")}if result.MothData == nil {t.Error("expected MothData to be non-nil")}// AudioMoth parser returns UTC+13 fixed offsetexpectedUTC := time.Date(2025, 2, 24, 8, 0, 0, 0, time.UTC)if !result.Timestamp.UTC().Equal(expectedUTC) {t.Errorf("expected UTC timestamp %v, got %v", expectedUTC, result.Timestamp.UTC())}})t.Run("falls back to filename timestamp", func(t *testing.T) {meta := &WAVMetadata{Comment: "",Artist: "",}result, err := ResolveTimestamp(meta, "20250224_210000.wav", "Pacific/Auckland", false)if err != nil {t.Fatalf("unexpected error: %v", err)}if result.IsAudioMoth {t.Error("expected IsAudioMoth to be false")}if result.Timestamp.IsZero() {t.Error("expected non-zero timestamp")}})t.Run("falls back to file mod time when enabled", func(t *testing.T) {modTime := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)meta := &WAVMetadata{Comment: "",Artist: "",FileModTime: modTime,}result, err := ResolveTimestamp(meta, "nopattern.wav", "Pacific/Auckland", true)if err != nil {t.Fatalf("unexpected error: %v", err)}if !result.Timestamp.Equal(modTime) {t.Errorf("expected timestamp %v, got %v", modTime, result.Timestamp)}})t.Run("errors when no timestamp available and file mod time disabled", func(t *testing.T) {meta := &WAVMetadata{Comment: "",Artist: "",}_, err := ResolveTimestamp(meta, "nopattern.wav", "Pacific/Auckland", false)if err == nil {t.Error("expected error when no timestamp available")}})t.Run("errors when no timestamp available and no file mod time", func(t *testing.T) {meta := &WAVMetadata{Comment: "",Artist: "",}_, err := ResolveTimestamp(meta, "nopattern.wav", "Pacific/Auckland", true)if err == nil {t.Error("expected error when no timestamp available")}})t.Run("AudioMoth detected but parse fails falls back to filename", func(t *testing.T) {meta := &WAVMetadata{Comment: "AudioMoth garbage data",Artist: "",}result, err := ResolveTimestamp(meta, "20250224_210000.wav", "Pacific/Auckland", false)if err != nil {t.Fatalf("unexpected error: %v", err)}if !result.IsAudioMoth {t.Error("expected IsAudioMoth to be true (detected even if parse failed)")}if result.MothData != nil {t.Error("expected MothData to be nil since parsing failed")}if result.Timestamp.IsZero() {t.Error("expected non-zero timestamp from filename fallback")}})}
package utilsimport ("database/sql""fmt""path/filepath""time"gonanoid "github.com/matoous/go-nanoid/v2")// FileIDAlphabet is the standard alphabet used for generating file IDsconst FileIDAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"// FileIDLength is the standard length for generated file IDsconst FileIDLength = 21// GenerateFileID generates a 21-character nanoid file ID using the standard alphabetfunc GenerateFileID() (string, error) {return gonanoid.Generate(FileIDAlphabet, FileIDLength)}// TimestampResult holds the result of timestamp resolution for a single filetype TimestampResult struct {Timestamp time.TimeIsAudioMoth boolMothData *AudioMothData}// ResolveTimestamp resolves a file's timestamp using the standard priority chain:// 1. AudioMoth comment parsing// 2. Filename timestamp parsing + timezone offset// 3. File modification time (if useFileModTime is true)//// Returns an error if no timestamp could be determined.func ResolveTimestamp(wavMeta *WAVMetadata, filePath string, timezoneID string, useFileModTime bool) (*TimestampResult, error) {result := &TimestampResult{}// Step 1: Try AudioMoth commentif IsAudioMoth(wavMeta.Comment, wavMeta.Artist) {result.IsAudioMoth = truemothData, err := ParseAudioMothComment(wavMeta.Comment)if err == nil {result.MothData = mothDataresult.Timestamp = mothData.Timestampreturn result, nil}// AudioMoth detected but parsing failed — fall through to filename}// Step 2: Try filename timestampif HasTimestampFilename(filePath) {filenameTimestamps, err := ParseFilenameTimestamps([]string{filepath.Base(filePath)})if err == nil {adjustedTimestamps, err := ApplyTimezoneOffset(filenameTimestamps, timezoneID)if err == nil && len(adjustedTimestamps) > 0 {result.Timestamp = adjustedTimestamps[0]return result, nil}}}// Step 3: File modification time fallback (optional)if useFileModTime && !wavMeta.FileModTime.IsZero() {result.Timestamp = wavMeta.FileModTimereturn result, nil}return nil, fmt.Errorf("cannot resolve timestamp (no AudioMoth, filename pattern, or file modification time)")}// FileProcessingResult holds all extracted metadata for a single filetype FileProcessingResult struct {FileName stringHash stringDuration float64SampleRate intTimestampLocal time.TimeIsAudioMoth boolMothData *AudioMothDataAstroData AstronomicalData}// ProcessSingleFile runs the full single-file processing pipeline:// WAV header parsing → XXH64 hash → timestamp resolution → astronomical data//// Set useFileModTime to true to allow file modification time as a timestamp fallback.func ProcessSingleFile(filePath string, latitude, longitude float64, timezoneID string, useFileModTime bool) (*FileProcessingResult, error) {// Step 1: Parse WAV headermetadata, err := ParseWAVHeader(filePath)if err != nil {return nil, fmt.Errorf("WAV header parsing failed: %w", err)}// Step 2: Calculate hashhash, err := ComputeXXH64(filePath)if err != nil {return nil, fmt.Errorf("hash calculation failed: %w", err)}// Step 3: Resolve timestamptsResult, err := ResolveTimestamp(metadata, filePath, timezoneID, useFileModTime)if err != nil {return nil, err}// Step 4: Calculate astronomical dataastroData := CalculateAstronomicalData(tsResult.Timestamp.UTC(),metadata.Duration,latitude,longitude,)return &FileProcessingResult{FileName: filepath.Base(filePath),Hash: hash,Duration: metadata.Duration,SampleRate: metadata.SampleRate,TimestampLocal: tsResult.Timestamp,IsAudioMoth: tsResult.IsAudioMoth,MothData: tsResult.MothData,AstroData: astroData,}, nil}// DBQueryable is an interface satisfied by both *sql.DB and *sql.Tx// for running duplicate hash checks against either.type DBQueryable interface {QueryRow(query string, args ...interface{}) *sql.Row}// CheckDuplicateHash checks if a file with the given XXH64 hash already exists.// Returns the existing file ID if found, or empty string if no duplicate.// Works with both *sql.DB and *sql.Tx.func CheckDuplicateHash(q DBQueryable, hash string) (existingID string, isDuplicate bool, err error) {err = q.QueryRow("SELECT id FROM file WHERE xxh64_hash = ? AND active = true",hash,).Scan(&existingID)if err == nil {return existingID, true, nil}if err == sql.ErrNoRows {return "", false, nil}return "", false, fmt.Errorf("duplicate check failed: %w", err)}
// Step 1: Parse WAV headermetadata, err := utils.ParseWAVHeader(filePath)if err != nil {return nil, fmt.Errorf("WAV header parsing failed: %w", err)}// Step 2: Calculate hashhash, err := utils.ComputeXXH64(filePath)
result, err := utils.ProcessSingleFile(filePath, location.Latitude, location.Longitude, location.TimezoneID, true)
// Step 3: Extract timestampvar timestampLocal time.Timevar isAudioMoth boolvar mothData *utils.AudioMothData// Try AudioMoth comment firstif utils.IsAudioMoth(metadata.Comment, metadata.Artist) {isAudioMoth = truemothData, err = utils.ParseAudioMothComment(metadata.Comment)if err == nil {timestampLocal = mothData.Timestamp} else {// AudioMoth detected but parsing failed - try filename// (Continue to filename parsing below)}}// If no AudioMoth timestamp, try filename timestampif timestampLocal.IsZero() {if utils.HasTimestampFilename(filePath) {// Parse filename timestamp (single-file array)filenameTimestamps, err := utils.ParseFilenameTimestamps([]string{filepath.Base(filePath)})if err == nil {// Only apply timezone if parsing succeededadjustedTimestamps, err := utils.ApplyTimezoneOffset(filenameTimestamps, location.TimezoneID)if err == nil && len(adjustedTimestamps) > 0 {timestampLocal = adjustedTimestamps[0]}// If timezone application fails, continue to file mod time fallback}// If filename parsing fails, continue to file mod time fallback}}// If still no timestamp, use file modification time as fallbackif timestampLocal.IsZero() {if !metadata.FileModTime.IsZero() {// Use file mod time (already in system local time)timestampLocal = metadata.FileModTime}}// If still no timestamp, return errorif timestampLocal.IsZero() {return nil, fmt.Errorf("cannot import file without timestamp (no AudioMoth, filename pattern, or file modification time)")}// Step 4: Calculate astronomical dataastroData := utils.CalculateAstronomicalData(timestampLocal.UTC(),metadata.Duration,location.Latitude,location.Longitude,)
FileName: filepath.Base(filePath),Hash: hash,Duration: metadata.Duration,SampleRate: metadata.SampleRate,TimestampLocal: timestampLocal,IsAudioMoth: isAudioMoth,MothData: mothData,AstroData: astroData,
FileName: result.FileName,Hash: result.Hash,Duration: result.Duration,SampleRate: result.SampleRate,TimestampLocal: result.TimestampLocal,IsAudioMoth: result.IsAudioMoth,MothData: result.MothData,AstroData: result.AstroData,
var existingID stringvar existingName stringerr = tx.QueryRowContext(ctx,"SELECT id, file_name FROM file WHERE xxh64_hash = ? AND active = true",fileData.Hash,).Scan(&existingID, &existingName)if err == nil {// Duplicate found - return existing file info
existingID, isDup, err := utils.CheckDuplicateHash(tx, fileData.Hash)if err != nil {return "", false, err}if isDup {
var existingID stringerr = database.QueryRow("SELECT id FROM file WHERE xxh64_hash = ? AND active = true", hash).Scan(&existingID)if err == nil {return fmt.Errorf("duplicate") // File already exists} else if err != sql.ErrNoRows {return fmt.Errorf("duplicate check failed: %v", err)}// Extract WAV metadatawavMeta, err := utils.ParseWAVHeader(filePath)
_, isDup, err := utils.CheckDuplicateHash(database, result.Hash)
// Try to parse AudioMoth comment first, fall back to filename parsingvar timestamp time.Timeif utils.IsAudioMoth(wavMeta.Comment, wavMeta.Artist) {mothData, err := utils.ParseAudioMothComment(wavMeta.Comment)if err == nil && mothData != nil {timestamp = mothData.Timestamp}}// Fall back to filename parsing if no AudioMoth timestampif timestamp.IsZero() {results, err := utils.ParseFilenameTimestamps([]string{filepath.Base(filePath)})if err != nil || len(results) == 0 {return fmt.Errorf("timestamp parsing failed: %v", err)}localTimes, err := utils.ApplyTimezoneOffset(results, timezoneID)if err != nil {return fmt.Errorf("timezone application failed: %v", err)}timestamp = localTimes[0]
if isDup {return fmt.Errorf("duplicate")
filepath.Base(filePath), hash, wavMeta.Duration, wavMeta.SampleRate,timestamp, astro.SolarNight, astro.CivilNight,astro.MoonPhase,
result.FileName, result.Hash, result.Duration, result.SampleRate,result.TimestampLocal, result.AstroData.SolarNight, result.AstroData.CivilNight,result.AstroData.MoonPhase,