package scanner import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "slices" "strconv" "strings" ) type DiscoveredAudioFile struct { Path string FileSize int64 FileMtimeNs int64 } type ScannedSong struct { Title string Artist string Album string Duration int Path string Format string BitRate int SampleRate int } // ffprobeFormat holds the JSON output from ffprobe format section type ffprobeFormat struct { Tags map[string]string `json:"tags"` Duration string `json:"duration"` BitRate string `json:"bit_rate"` FormatName string `json:"format_name"` } type ffprobeStream struct { CodecType string `json:"codec_type"` SampleRate string `json:"sample_rate"` } // ffprobeOutput holds the full ffprobe JSON output type ffprobeOutput struct { Format ffprobeFormat `json:"format"` Streams []ffprobeStream `json:"streams"` } func ScanDirectory(dirPath string) ([]ScannedSong, error) { files, err := ListAudioFiles(dirPath) if err != nil { return nil, err } songs := make([]ScannedSong, 0, len(files)) for _, file := range files { song, err := ProbeAudioFile(file.Path) if err != nil { continue } songs = append(songs, song) } return songs, nil } func ListAudioFiles(dirPath string) ([]DiscoveredAudioFile, error) { files := []DiscoveredAudioFile{} err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } if !isAudioFile(path) { return nil } files = append(files, DiscoveredAudioFile{ Path: path, FileSize: info.Size(), FileMtimeNs: info.ModTime().UnixNano(), }) return nil }) return files, err } func ProbeAudioFile(filePath string) (ScannedSong, error) { if _, err := exec.LookPath("ffprobe"); err != nil { return ScannedSong{}, fmt.Errorf("ffprobe not found in PATH: %v", err) } return processAudioFile(filePath) } func processAudioFile(filePath string) (ScannedSong, error) { cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath) output, err := cmd.Output() if err != nil { return ScannedSong{}, fmt.Errorf("ffprobe failed for %s: %v", filePath, err) } var probeOutput ffprobeOutput if err := json.Unmarshal(output, &probeOutput); err != nil { return ScannedSong{}, fmt.Errorf("failed to parse ffprobe output for %s: %v", filePath, err) } tags := probeOutput.Format.Tags if tags == nil { tags = make(map[string]string) } title := tags["title"] if title == "" { title = filepath.Base(filePath) } artist := tags["artist"] if artist == "" { artist = "Unknown Artist" } album := tags["album"] if album == "" { album = "Unknown Album" } duration := 0 if probeOutput.Format.Duration != "" { if d, err := strconv.ParseFloat(probeOutput.Format.Duration, 64); err == nil { duration = int(d) } } bitRate := 0 if probeOutput.Format.BitRate != "" { if value, err := strconv.Atoi(probeOutput.Format.BitRate); err == nil { bitRate = value } } sampleRate := 0 for _, stream := range probeOutput.Streams { if stream.CodecType != "audio" || stream.SampleRate == "" { continue } if value, err := strconv.Atoi(stream.SampleRate); err == nil { sampleRate = value break } } format := probeOutput.Format.FormatName if format == "" { format = strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") } return ScannedSong{ Title: title, Artist: artist, Album: album, Duration: duration, Path: filePath, Format: format, BitRate: bitRate, SampleRate: sampleRate, }, nil } func isAudioFile(path string) bool { ext := strings.ToLower(filepath.Ext(path)) audioExtensions := []string{".mp3", ".flac", ".m4a", ".wav", ".ogg"} return slices.Contains(audioExtensions, ext) }