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
+}