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 ( ") + 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) } } 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 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 } 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 +}