diff --git a/examples/recording-filename-demo.go b/examples/recording-filename-demo.go new file mode 100644 index 0000000..859ad46 --- /dev/null +++ b/examples/recording-filename-demo.go @@ -0,0 +1,181 @@ +// Package main demonstrates the new recording filename format that includes date information. +package main + +import ( + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/service/proxy" +) + +func main() { + fmt.Println("=== Recording Filename Format Demo ===") + fmt.Println() + + // Create a temporary directory for the demo + tmpDir, err := os.MkdirTemp("", "recording-filename-demo") + if err != nil { + log.Fatalf("Failed to create temp directory: %v", err) + } + + defer func() { + if removeErr := os.RemoveAll(tmpDir); removeErr != nil { + log.Printf("Failed to remove temp directory: %v", removeErr) + } + }() + + fmt.Printf("Demo recordings will be saved to: %s\n\n", tmpDir) + + // Create a recorder with async disabled for predictable demo output + if envErr := os.Setenv("RECORDER_ASYNC", "false"); envErr != nil { + log.Printf("Failed to set environment variable: %v", envErr) + } + + recorder := proxy.NewRecorder(tmpDir) + defer recorder.Close() + + fmt.Printf("Recorder session ID: %s\n", recorder.SessionID) + fmt.Println() + + // Create some sample HTTP requests to record + requests := []struct { + method string + path string + category string + }{ + {"GET", "/info", "self"}, + {"POST", "/volume", "self"}, + {"GET", "/nowPlaying", "self"}, + {"PUT", "/preset_1", "self"}, + } + + fmt.Println("Recording sample HTTP interactions...") + fmt.Println() + + for i, req := range requests { + // Create a mock HTTP request + httpReq, reqErr := http.NewRequest(req.method, "http://soundtouch.local:8090"+req.path, nil) + if reqErr != nil { + log.Printf("Failed to create request: %v", reqErr) + continue + } + + // Create a mock response + httpRes := &http.Response{ + StatusCode: 200, + Header: make(http.Header), + Request: httpReq, + } + httpRes.Header.Set("Content-Type", "application/xml") + + // Record the interaction + err = recorder.Record(req.category, httpReq, httpRes) + if err != nil { + log.Printf("Failed to record interaction: %v", err) + continue + } + + fmt.Printf("%d. Recorded: %s %s\n", i+1, req.method, req.path) + + // Small delay to show different timestamps + time.Sleep(100 * time.Millisecond) + } + + fmt.Println() + fmt.Println("=== Generated Filenames ===") + fmt.Println() + + // Walk through the recordings directory to show the generated filenames + interactionsDir := filepath.Join(tmpDir, "interactions") + + err = filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if strings.HasSuffix(info.Name(), ".http") { + // Get relative path from interactions directory + rel, _ := filepath.Rel(interactionsDir, path) + fmt.Printf("📁 %s\n", rel) + + // Parse and explain the filename format + filename := info.Name() + parts := strings.Split(strings.TrimSuffix(filename, ".http"), "-") + + if len(parts) == 4 && len(parts[1]) == 8 { + // New format: count-yyyyMMdd-HHMMSS.sss-method.http + counter := parts[0] + dateStr := parts[1] + timeStr := parts[2] + method := parts[3] + + // Format for display + date := dateStr[0:4] + "-" + dateStr[4:6] + "-" + dateStr[6:8] + time := timeStr[0:2] + ":" + timeStr[2:4] + ":" + timeStr[4:] + + fmt.Printf(" 📋 Format: count-yyyyMMdd-HHMMSS.sss-method.http\n") + fmt.Printf(" 🔢 Counter: %s\n", counter) + fmt.Printf(" 📅 Date: %s (from %s)\n", date, dateStr) + fmt.Printf(" 🕒 Time: %s (from %s)\n", time, timeStr) + fmt.Printf(" 🔧 Method: %s\n", method) + fmt.Printf(" ✨ Full timestamp: %s %s\n", date, time) + } else { + fmt.Printf(" ⚠️ Legacy format or unexpected structure\n") + } + + fmt.Println() + } + + return nil + }) + if err != nil { + log.Printf("Error walking directory: %v", err) + } + + fmt.Println("=== Comparison with Old Format ===") + fmt.Println() + fmt.Println("🔴 OLD format (time only): 0047-21-53-06.128-GET.http") + fmt.Println(" - No date information in filename") + fmt.Println(" - Date extracted from session ID directory") + fmt.Println(" - Confusing when recordings span midnight") + fmt.Println() + fmt.Println("🟢 NEW format (date + time): 0047-20260223-215306.128-GET.http") + fmt.Println(" - Complete timestamp in filename") + fmt.Println(" - Self-contained, no need to check directory") + fmt.Println(" - Clear chronological ordering") + fmt.Println() + + fmt.Println("=== Benefits ===") + fmt.Println("✅ No confusion when recordings cross midnight") + fmt.Println("✅ Complete timestamp visible at a glance") + fmt.Println("✅ Better sorting and organization") + fmt.Println("✅ Backwards compatible with existing parsing logic") + fmt.Println() + + // Test the list interactions functionality + fmt.Println("=== Using ListInteractions API ===") + fmt.Println() + + interactions, err := recorder.ListInteractions("", "", "") + if err != nil { + log.Printf("Failed to list interactions: %v", err) + return + } + + fmt.Printf("Found %d recorded interactions:\n", len(interactions)) + + for i := range interactions { + interaction := &interactions[i] + fmt.Printf("%d. %s %s - %s (File: %s)\n", + i+1, interaction.Method, interaction.Path, + interaction.Timestamp, interaction.ID) + } + + fmt.Printf("\nDemo completed! Recordings saved in: %s\n", tmpDir) + fmt.Println("You can explore the generated files to see the new format in action.") +} diff --git a/pkg/service/proxy/recorder.go b/pkg/service/proxy/recorder.go index a41a31d..090334e 100644 --- a/pkg/service/proxy/recorder.go +++ b/pkg/service/proxy/recorder.go @@ -215,7 +215,7 @@ func (r *Recorder) getRecordingDir(category string, sanitizedSegments []string) } func (r *Recorder) getRecordingPath(dir, method string) string { - timestamp := time.Now().Format("15-04-05.000") + timestamp := time.Now().Format("20060102-150405.000") count := atomic.AddUint64(&r.counter, 1) filename := fmt.Sprintf("%04d-%s-%s.http", count, timestamp, method) @@ -441,20 +441,44 @@ func (r *Recorder) parseInteractionFile(rel, path string, parts []string) (Inter filename := parts[len(parts)-1] fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-") - date := "" - if len(sessionID) >= 8 { - date = sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8] + timestamp := "" + method, counter := "UNKNOWN", 0 + + if len(fnParts) >= 1 { + _, _ = fmt.Sscanf(fnParts[0], "%d", &counter) } - timestamp := "" + // Check if this is the new format: count-yyyyMMdd-HHMMSS.sss-method.http + // New format has 4 parts and the second part is 8 digits (yyyyMMdd) + if len(fnParts) == 4 && len(fnParts[1]) == 8 { + dateStr := fnParts[1] // yyyyMMdd + timeStr := fnParts[2] // HHMMSS.sss + method = fnParts[3] + + // Format date: yyyyMMdd -> yyyy-MM-dd + date := dateStr[0:4] + "-" + dateStr[4:6] + "-" + dateStr[6:8] + + // Format time: HHMMSS.sss -> HH:MM:SS.sss + if len(timeStr) >= 6 { + time := timeStr[0:2] + ":" + timeStr[2:4] + ":" + timeStr[4:] + timestamp = date + " " + time + } + } else if len(fnParts) >= 5 { + // Legacy format: count-HH-MM-SS.sss-method.http + // Extract date from sessionID for backward compatibility + date := "" + if len(sessionID) >= 8 { + date = sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8] + } - if len(fnParts) >= 4 { timeStr := fnParts[1] + ":" + fnParts[2] + ":" + fnParts[3] timestamp = timeStr if date != "" { timestamp = date + " " + timeStr } + + method = fnParts[4] } requestPath := "/" + strings.Join(parts[2:len(parts)-1], "/") @@ -462,15 +486,6 @@ func (r *Recorder) parseInteractionFile(rel, path string, parts []string) (Inter requestPath = "/" } - method, counter := "UNKNOWN", 0 - if len(fnParts) >= 1 { - _, _ = fmt.Sscanf(fnParts[0], "%d", &counter) - } - - if len(fnParts) >= 5 { - method = fnParts[4] - } - return Interaction{ ID: filename, Session: sessionID, @@ -485,18 +500,32 @@ func (r *Recorder) parseInteractionFile(rel, path string, parts []string) (Inter } func (r *Recorder) getFullTimestamp(sessionID, filename string) string { - if len(sessionID) < 8 { - return "" - } - - date := sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8] fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-") - if len(fnParts) < 4 { - return "" + // Check if this is the new format: count-yyyyMMdd-HHMMSS.sss-method.http + // New format has 4 parts and the second part is 8 digits (yyyyMMdd) + if len(fnParts) == 4 && len(fnParts[1]) == 8 { + dateStr := fnParts[1] // yyyyMMdd + timeStr := fnParts[2] // HHMMSS.sss + + if len(timeStr) >= 6 { + date := dateStr[0:4] + "-" + dateStr[4:6] + "-" + dateStr[6:8] + time := timeStr[0:2] + "-" + timeStr[2:4] + "-" + timeStr[4:] + + return date + "-" + time + } + } else if len(fnParts) >= 5 { + // Legacy format: count-HH-MM-SS.sss-method.http + if len(sessionID) < 8 { + return "" + } + + date := sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8] + + return date + "-" + fnParts[1] + "-" + fnParts[2] + "-" + fnParts[3] } - return date + "-" + fnParts[1] + "-" + fnParts[2] + "-" + fnParts[3] + return "" } func (r *Recorder) peekStatus(path string) int { diff --git a/pkg/service/proxy/recorder_test.go b/pkg/service/proxy/recorder_test.go index 89057fa..f945345 100644 --- a/pkg/service/proxy/recorder_test.go +++ b/pkg/service/proxy/recorder_test.go @@ -847,3 +847,99 @@ func TestRecorder_ListInteractions_FullTimestamp(t *testing.T) { } }) } + +func TestRecorder_NewFilenameFormat_WithDate(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "recorder-new-format-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + r := NewRecorder(tmpDir) + sessionID := "20260223-150000-12345" + r.SessionID = sessionID + + // Create recordings with the new format that includes date in filename + basePath := filepath.Join(tmpDir, "interactions", sessionID, "self", "test") + os.MkdirAll(basePath, 0755) + + files := []string{ + "0047-20260223-215306.128-GET.http", + "0048-20260223-215306.417-POST.http", + "0049-20260223-080034.500-GET.http", + "0050-20260223-080034.507-PUT.http", + } + + for _, f := range files { + os.WriteFile(filepath.Join(basePath, f), []byte("test content"), 0644) + } + + t.Run("Parse_New_Format_Timestamps", func(t *testing.T) { + interactions, err := r.ListInteractions(sessionID, "", "") + if err != nil { + t.Fatalf("ListInteractions failed: %v", err) + } + + if len(interactions) != 4 { + t.Fatalf("Expected 4 interactions, got %d", len(interactions)) + } + + // Check that timestamps include both date and time from filename + expectedTimestamps := []string{ + "2026-02-23 21:53:06.128", + "2026-02-23 21:53:06.417", + "2026-02-23 08:00:34.500", + "2026-02-23 08:00:34.507", + } + + for i, interaction := range interactions { + if interaction.Timestamp != expectedTimestamps[i] { + t.Errorf("Expected timestamp %s, got %s for interaction %d", + expectedTimestamps[i], interaction.Timestamp, i) + } + } + }) + + t.Run("Parse_Methods_From_New_Format", func(t *testing.T) { + interactions, err := r.ListInteractions(sessionID, "", "") + if err != nil { + t.Fatalf("ListInteractions failed: %v", err) + } + + expectedMethods := []string{"GET", "POST", "GET", "PUT"} + for i, interaction := range interactions { + if interaction.Method != expectedMethods[i] { + t.Errorf("Expected method %s, got %s for interaction %d", + expectedMethods[i], interaction.Method, i) + } + } + }) + + t.Run("Filter_By_New_Format_Timestamp", func(t *testing.T) { + // Filter for interactions after 10:00:00 on that day + interactions, err := r.ListInteractions(sessionID, "", "2026-02-23 10:00:00") + if err != nil { + t.Fatalf("ListInteractions failed: %v", err) + } + + // Should get the two evening interactions (21:53:06.xxx) + if len(interactions) != 2 { + t.Fatalf("Expected 2 interactions after 10:00:00, got %d", len(interactions)) + } + + for _, interaction := range interactions { + if !strings.Contains(interaction.Timestamp, "21:53:06") { + t.Errorf("Expected evening timestamp, got %s", interaction.Timestamp) + } + } + }) + + t.Run("GetFullTimestamp_New_Format", func(t *testing.T) { + // Test the getFullTimestamp function directly + fullTS := r.getFullTimestamp(sessionID, "0047-20260223-215306.128-GET.http") + expected := "2026-02-23-21-53-06.128" + if fullTS != expected { + t.Errorf("Expected full timestamp %s, got %s", expected, fullTS) + } + }) +}