From d16c6f15531058c88c35412c48f41ed0015678a3 Mon Sep 17 00:00:00 2001 From: Chigozirim Igweamaka Date: Sat, 3 Aug 2024 10:07:55 +0100 Subject: [PATCH 1/5] Write function to get metadata from song file --- wav/wav.go | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/wav/wav.go b/wav/wav.go index b55453b..010ac5d 100644 --- a/wav/wav.go +++ b/wav/wav.go @@ -3,10 +3,12 @@ package wav import ( "bytes" "encoding/binary" + "encoding/json" "errors" "fmt" "io/ioutil" "os" + "os/exec" ) // WavHeader defines the structure of a WAV header @@ -148,3 +150,53 @@ func WavBytesToSamples(input []byte) ([]float64, error) { return output, nil } + +// FFmpegMetadata represents the metadata structure returned by ffprobe. +type FFmpegMetadata struct { + Streams []struct { + Index int `json:"index"` + CodecName string `json:"codec_name"` + CodecLongName string `json:"codec_long_name"` + CodecType string `json:"codec_type"` + SampleFmt string `json:"sample_fmt"` + SampleRate string `json:"sample_rate"` + Channels int `json:"channels"` + ChannelLayout string `json:"channel_layout"` + BitsPerSample int `json:"bits_per_sample"` + Duration string `json:"duration"` + BitRate string `json:"bit_rate"` + Disposition map[string]int `json:"disposition"` + Tags map[string]string `json:"tags"` + } `json:"streams"` + Format struct { + Streams int `json:"nb_streams"` + FormFilename string `json:"filename"` + NbatName string `json:"format_name"` + FormatLongName string `json:"format_long_name"` + StartTime string `json:"start_time"` + Duration string `json:"duration"` + Size string `json:"size"` + BitRate string `json:"bit_rate"` + Tags map[string]string `json:"tags"` + } `json:"format"` +} + +// GetMetadata retrieves metadata from a file using ffprobe. +func GetMetadata(filePath string) (FFmpegMetadata, error) { + var metadata FFmpegMetadata + + cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath) + var out bytes.Buffer + cmd.Stdout = &out + err := cmd.Run() + if err != nil { + return metadata, err + } + + err = json.Unmarshal(out.Bytes(), &metadata) + if err != nil { + return metadata, err + } + + return metadata, nil +} From de40d13c6fd09bd294ed49241bd94b4993426d53 Mon Sep 17 00:00:00 2001 From: Chigozirim Igweamaka Date: Sat, 3 Aug 2024 10:23:18 +0100 Subject: [PATCH 2/5] ConvertToWAV: Use temporary file to avoid FFmpeg overwrite errors --- wav/convert.go | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/wav/convert.go b/wav/convert.go index a0c17f5..aa5366b 100644 --- a/wav/convert.go +++ b/wav/convert.go @@ -8,8 +8,9 @@ import ( "strings" ) -func ConvertToWAV(inputFilePath string, channels int) (wavFilePath string, errr error) { - _, err := os.Stat(inputFilePath) +// ConvertToWAV converts an input audio file to WAV format with specified channels. +func ConvertToWAV(inputFilePath string, channels int) (wavFilePath string, err error) { + _, err = os.Stat(inputFilePath) if err != nil { return "", fmt.Errorf("input file does not exist: %v", err) } @@ -21,15 +22,19 @@ func ConvertToWAV(inputFilePath string, channels int) (wavFilePath string, errr fileExt := filepath.Ext(inputFilePath) outputFile := strings.TrimSuffix(inputFilePath, fileExt) + ".wav" - // Execute FFmpeg command to convert to WAV format with one channel (mono) + // Output file may already exists. If it does FFmpeg will fail as + // it cannot edit existing files in-place. Use a temporary file. + tmpFile := filepath.Join(filepath.Dir(outputFile), "tmp_"+filepath.Base(outputFile)) + defer os.Remove(tmpFile) + cmd := exec.Command( "ffmpeg", - "-y", // Automatically overwrite if file exists + "-y", "-i", inputFilePath, - "-c", "pcm_s16le", // Output PCM signed 16-bit little-endian audio + "-c", "pcm_s16le", "-ar", "44100", "-ac", fmt.Sprint(channels), - outputFile, + tmpFile, ) output, err := cmd.CombinedOutput() @@ -37,6 +42,12 @@ func ConvertToWAV(inputFilePath string, channels int) (wavFilePath string, errr return "", fmt.Errorf("failed to convert to WAV: %v, output %v", err, string(output)) } + // Rename the temporary file to the output file + err = os.Rename(tmpFile, outputFile) + if err != nil { + return "", fmt.Errorf("failed to rename temporary file to output file: %v", err) + } + return outputFile, nil } From 2fb960a2d7e43d240126c809005c1419d9370ed5 Mon Sep 17 00:00:00 2001 From: Chigozirim Igweamaka Date: Sat, 3 Aug 2024 10:24:28 +0100 Subject: [PATCH 3/5] Add tags to songs downloaded from YT --- spotify/downloader.go | 51 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/spotify/downloader.go b/spotify/downloader.go index 276cbeb..fac7471 100644 --- a/spotify/downloader.go +++ b/spotify/downloader.go @@ -7,11 +7,13 @@ import ( "io" "log/slog" "os" + "os/exec" "path/filepath" "runtime" "song-recognition/shazam" "song-recognition/utils" "song-recognition/wav" + "strings" "sync" "time" @@ -139,7 +141,7 @@ func dlTrack(tracks []Track, path string) (int, error) { return } - err = processAndSaveSong(filePath, trackCopy.Title, trackCopy.Artist, ytID) + err = ProcessAndSaveSong(filePath, trackCopy.Title, trackCopy.Artist, ytID) if err != nil { logMessage := fmt.Sprintf("Failed to process song ('%s' by '%s')", trackCopy.Title, trackCopy.Artist) logger.ErrorContext(ctx, logMessage, slog.Any("error", xerrors.New(err))) @@ -148,8 +150,17 @@ func dlTrack(tracks []Track, path string) (int, error) { utils.DeleteFile(filepath.Join(path, fileName+".m4a")) + wavFilePath := filepath.Join(path, fileName+".wav") + + if err := addTags(wavFilePath, *trackCopy); err != nil { + logMessage := fmt.Sprintf("Error adding tags: %s", filePath+".wav") + logger.ErrorContext(ctx, logMessage, slog.Any("error", xerrors.New(err))) + + return + } + if DELETE_SONG_FILE { - utils.DeleteFile(filepath.Join(path, fileName+".wav")) + utils.DeleteFile(wavFilePath) } fmt.Printf("'%s' by '%s' was downloaded\n", track.Title, track.Artist) @@ -223,7 +234,41 @@ func downloadYTaudio(id, path, filePath string) error { return nil } -func processAndSaveSong(songFilePath, songTitle, songArtist, ytID string) error { +func addTags(file string, track Track) error { + // Create a temporary file name by appending "2" before the extension + tempFile := file + index := strings.Index(file, ".wav") + if index != -1 { + baseName := tempFile[:index] // Filename without extension ('/path/to/title - artist') + tempFile = baseName + "2" + ".wav" // Temporary filename ('/path/to/title - artist2.wav') + } + + // Execute FFmpeg command to add metadata tags + cmd := exec.Command( + "ffmpeg", + "-i", file, // Input file path + "-c", "copy", + "-metadata", fmt.Sprintf("album_artist=%s", track.Artist), + "-metadata", fmt.Sprintf("title=%s", track.Title), + "-metadata", fmt.Sprintf("artist=%s", track.Artist), + "-metadata", fmt.Sprintf("album=%s", track.Album), + tempFile, // Output file path (temporary) + ) + + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to add tags: %v, output: %s", err, string(out)) + } + + // Rename the temporary file to the original filename + if err := os.Rename(tempFile, file); err != nil { + return fmt.Errorf("failed to rename file: %v", err) + } + + return nil +} + +func ProcessAndSaveSong(songFilePath, songTitle, songArtist, ytID string) error { db, err := utils.NewDbClient() if err != nil { return err From f3afebc08a83a5d0c0f657717800c89f5d3fe386 Mon Sep 17 00:00:00 2001 From: Chigozirim Igweamaka Date: Sat, 3 Aug 2024 10:25:32 +0100 Subject: [PATCH 4/5] Add new command to save local song files to DB --- cmdHandlers.go | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 15 ++++++++-- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/cmdHandlers.go b/cmdHandlers.go index c43f638..a8c9173 100644 --- a/cmdHandlers.go +++ b/cmdHandlers.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "log/slog" + "math" "net/http" "os" "path/filepath" @@ -13,6 +14,7 @@ import ( "song-recognition/spotify" "song-recognition/utils" "song-recognition/wav" + "strconv" "strings" "github.com/fatih/color" @@ -235,3 +237,77 @@ func erase(songsDir string) { fmt.Println("Erase complete") } + +// index processes the path, whether it's a directory or a single file. +func save(path string, force bool) { + fileInfo, err := os.Stat(path) + if err != nil { + fmt.Printf("Error stating path %v: %v\n", path, err) + return + } + + if fileInfo.IsDir() { + err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error { + if err != nil { + fmt.Printf("Error walking the path %v: %v\n", filePath, err) + return err + } + // Process only files, skip directories + if !info.IsDir() { + err := saveSong(filePath, force) + if err != nil { + fmt.Printf("Error indexing song (%v): %v\n", filePath, err) + } + } + return nil + }) + if err != nil { + fmt.Printf("Error walking the directory %v: %v\n", path, err) + } + } else { + // If it's a file, process it directly + err := saveSong(path, force) + if err != nil { + fmt.Printf("Error indexing song (%v): %v\n", path, err) + } + } +} + +func saveSong(filePath string, force bool) error { + metadata, err := wav.GetMetadata(filePath) + if err != nil { + return err + } + + durationFloat, err := strconv.ParseFloat(metadata.Format.Duration, 64) + if err != nil { + return fmt.Errorf("failed to parse duration to float: %v", err) + } + + tags := metadata.Format.Tags + track := &spotify.Track{ + Album: tags["album"], + Artist: tags["artist"], + Title: tags["title"], + Duration: int(math.Round(durationFloat)), + } + + ytID, err := spotify.GetYoutubeId(*track) + if err != nil && !force { + return fmt.Errorf("failed to get YouTube ID for song: %v", err) + } + + if track.Title == "" { + return fmt.Errorf("no title found in metadata") + } + if track.Artist == "" { + return fmt.Errorf("no artist found in metadata") + } + + err = spotify.ProcessAndSaveSong(filePath, track.Title, track.Artist, ytID) + if err != nil { + return fmt.Errorf("failed to process or save song: %v", err) + } + + return nil +} diff --git a/main.go b/main.go index e415643..7836995 100644 --- a/main.go +++ b/main.go @@ -30,7 +30,7 @@ func main() { } if len(os.Args) < 2 { - fmt.Println("Expected 'find', 'download', 'erase', or 'serve' subcommands") + fmt.Println("Expected 'find', 'download', 'erase', 'save', or 'serve' subcommands") os.Exit(1) } @@ -57,8 +57,19 @@ func main() { serve(*protocol, *port) case "erase": erase(SONGS_DIR) + case "save": + indexCmd := flag.NewFlagSet("save", flag.ExitOnError) + force := indexCmd.Bool("force", false, "save song with or without YouTube ID") + indexCmd.BoolVar(force, "f", false, "save song with or without YouTube ID (shorthand)") + indexCmd.Parse(os.Args[2:]) + if indexCmd.NArg() < 1 { + fmt.Println("Usage: main.go save [-f|--force] ") + os.Exit(1) + } + filePath := indexCmd.Arg(0) + save(filePath, *force) default: - fmt.Println("Expected 'find', 'download', 'erase', or 'serve' subcommands") + fmt.Println("Expected 'find', 'download', 'erase', 'save', or 'serve' subcommands") os.Exit(1) } } From a439b87af8872f92643d34d4f3955f5d81a64dbb Mon Sep 17 00:00:00 2001 From: Chigozirim Igweamaka Date: Sat, 3 Aug 2024 10:26:07 +0100 Subject: [PATCH 5/5] Filter out matches with empty YouTubeID --- client/src/components/CarouselSliders.js | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/client/src/components/CarouselSliders.js b/client/src/components/CarouselSliders.js index 9312473..ece590c 100644 --- a/client/src/components/CarouselSliders.js +++ b/client/src/components/CarouselSliders.js @@ -8,11 +8,16 @@ const CarouselSliders = (props) => { useEffect(() => { if (props.matches.length > 0) { - const firstVideoID = props.matches[0].YouTubeID; - document - .getElementById(`slide-${firstVideoID}`) - .scrollIntoView({ behavior: "smooth" }); - setActiveVideoID(firstVideoID); + // Filter out matches with empty YouTubeID + const validMatches = props.matches.filter((match) => match.YouTubeID); + + if (validMatches.length > 0) { + const firstVideoID = validMatches[0].YouTubeID; + document + .getElementById(`slide-${firstVideoID}`) + .scrollIntoView({ behavior: "smooth" }); + setActiveVideoID(firstVideoID); + } } }, [props.matches]); @@ -41,7 +46,9 @@ const CarouselSliders = (props) => {
{!props.matches.length ? null : (
- {props.matches.map((match, index) => { + {props.matches + .filter((match) => match.YouTubeID) // Filter out matches with empty YouTubeID + .map((match, index) => { const start = (parseInt(match.Timestamp) / 1000) | 0; return ( @@ -66,7 +73,9 @@ const CarouselSliders = (props) => { )}
- {props.matches.map((match, _) => { + {props.matches + .filter((match) => match.YouTubeID) + .map((match, _) => { return (