Merge pull request #10 from cgzirim/development

Add new command to save local song files to DB
This commit is contained in:
Chigozirim Igweamaka 2024-08-03 10:28:57 +01:00 committed by GitHub
commit 843635046f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 222 additions and 18 deletions

View file

@ -8,11 +8,16 @@ const CarouselSliders = (props) => {
useEffect(() => { useEffect(() => {
if (props.matches.length > 0) { if (props.matches.length > 0) {
const firstVideoID = props.matches[0].YouTubeID; // Filter out matches with empty YouTubeID
document const validMatches = props.matches.filter((match) => match.YouTubeID);
.getElementById(`slide-${firstVideoID}`)
.scrollIntoView({ behavior: "smooth" }); if (validMatches.length > 0) {
setActiveVideoID(firstVideoID); const firstVideoID = validMatches[0].YouTubeID;
document
.getElementById(`slide-${firstVideoID}`)
.scrollIntoView({ behavior: "smooth" });
setActiveVideoID(firstVideoID);
}
} }
}, [props.matches]); }, [props.matches]);
@ -41,7 +46,9 @@ const CarouselSliders = (props) => {
<div className={styles.CarouselSliders}> <div className={styles.CarouselSliders}>
{!props.matches.length ? null : ( {!props.matches.length ? null : (
<div className={styles.Slider}> <div className={styles.Slider}>
{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; const start = (parseInt(match.Timestamp) / 1000) | 0;
return ( return (
@ -66,7 +73,9 @@ const CarouselSliders = (props) => {
)} )}
<div className={styles.Circles}> <div className={styles.Circles}>
{props.matches.map((match, _) => { {props.matches
.filter((match) => match.YouTubeID)
.map((match, _) => {
return ( return (
<a <a
key={match.YouTubeID} key={match.YouTubeID}

View file

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"log" "log"
"log/slog" "log/slog"
"math"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -13,6 +14,7 @@ import (
"song-recognition/spotify" "song-recognition/spotify"
"song-recognition/utils" "song-recognition/utils"
"song-recognition/wav" "song-recognition/wav"
"strconv"
"strings" "strings"
"github.com/fatih/color" "github.com/fatih/color"
@ -235,3 +237,77 @@ func erase(songsDir string) {
fmt.Println("Erase complete") 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
}

15
main.go
View file

@ -30,7 +30,7 @@ func main() {
} }
if len(os.Args) < 2 { 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) os.Exit(1)
} }
@ -57,8 +57,19 @@ func main() {
serve(*protocol, *port) serve(*protocol, *port)
case "erase": case "erase":
erase(SONGS_DIR) 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] <path_to_wav_file_or_dir>")
os.Exit(1)
}
filePath := indexCmd.Arg(0)
save(filePath, *force)
default: default:
fmt.Println("Expected 'find', 'download', 'erase', or 'serve' subcommands") fmt.Println("Expected 'find', 'download', 'erase', 'save', or 'serve' subcommands")
os.Exit(1) os.Exit(1)
} }
} }

View file

@ -7,11 +7,13 @@ import (
"io" "io"
"log/slog" "log/slog"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"song-recognition/shazam" "song-recognition/shazam"
"song-recognition/utils" "song-recognition/utils"
"song-recognition/wav" "song-recognition/wav"
"strings"
"sync" "sync"
"time" "time"
@ -139,7 +141,7 @@ func dlTrack(tracks []Track, path string) (int, error) {
return return
} }
err = processAndSaveSong(filePath, trackCopy.Title, trackCopy.Artist, ytID) err = ProcessAndSaveSong(filePath, trackCopy.Title, trackCopy.Artist, ytID)
if err != nil { if err != nil {
logMessage := fmt.Sprintf("Failed to process song ('%s' by '%s')", trackCopy.Title, trackCopy.Artist) logMessage := fmt.Sprintf("Failed to process song ('%s' by '%s')", trackCopy.Title, trackCopy.Artist)
logger.ErrorContext(ctx, logMessage, slog.Any("error", xerrors.New(err))) 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")) 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 { 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) 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 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() db, err := utils.NewDbClient()
if err != nil { if err != nil {
return err return err

View file

@ -8,8 +8,9 @@ import (
"strings" "strings"
) )
func ConvertToWAV(inputFilePath string, channels int) (wavFilePath string, errr error) { // ConvertToWAV converts an input audio file to WAV format with specified channels.
_, err := os.Stat(inputFilePath) func ConvertToWAV(inputFilePath string, channels int) (wavFilePath string, err error) {
_, err = os.Stat(inputFilePath)
if err != nil { if err != nil {
return "", fmt.Errorf("input file does not exist: %v", err) 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) fileExt := filepath.Ext(inputFilePath)
outputFile := strings.TrimSuffix(inputFilePath, fileExt) + ".wav" 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( cmd := exec.Command(
"ffmpeg", "ffmpeg",
"-y", // Automatically overwrite if file exists "-y",
"-i", inputFilePath, "-i", inputFilePath,
"-c", "pcm_s16le", // Output PCM signed 16-bit little-endian audio "-c", "pcm_s16le",
"-ar", "44100", "-ar", "44100",
"-ac", fmt.Sprint(channels), "-ac", fmt.Sprint(channels),
outputFile, tmpFile,
) )
output, err := cmd.CombinedOutput() 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)) 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 return outputFile, nil
} }

View file

@ -3,10 +3,12 @@ package wav
import ( import (
"bytes" "bytes"
"encoding/binary" "encoding/binary"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"os" "os"
"os/exec"
) )
// WavHeader defines the structure of a WAV header // WavHeader defines the structure of a WAV header
@ -148,3 +150,53 @@ func WavBytesToSamples(input []byte) ([]float64, error) {
return output, nil 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
}