mirror of
https://github.com/cgzirim/seek-tune.git
synced 2025-12-17 17:04:22 +00:00
Merge pull request #10 from cgzirim/development
Add new command to save local song files to DB
This commit is contained in:
commit
843635046f
6 changed files with 222 additions and 18 deletions
|
|
@ -8,12 +8,17 @@ const CarouselSliders = (props) => {
|
|||
|
||||
useEffect(() => {
|
||||
if (props.matches.length > 0) {
|
||||
const firstVideoID = props.matches[0].YouTubeID;
|
||||
// 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]);
|
||||
|
||||
const onReady = (event, videoId) => {
|
||||
|
|
@ -41,7 +46,9 @@ const CarouselSliders = (props) => {
|
|||
<div className={styles.CarouselSliders}>
|
||||
{!props.matches.length ? null : (
|
||||
<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;
|
||||
|
||||
return (
|
||||
|
|
@ -66,7 +73,9 @@ const CarouselSliders = (props) => {
|
|||
)}
|
||||
|
||||
<div className={styles.Circles}>
|
||||
{props.matches.map((match, _) => {
|
||||
{props.matches
|
||||
.filter((match) => match.YouTubeID)
|
||||
.map((match, _) => {
|
||||
return (
|
||||
<a
|
||||
key={match.YouTubeID}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
15
main.go
15
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] <path_to_wav_file_or_dir>")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
52
wav/wav.go
52
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue