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,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}
|
||||||
|
|
|
||||||
|
|
@ -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
15
main.go
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
52
wav/wav.go
52
wav/wav.go
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue