Merge pull request #40 from KaNaDaAT/main

Spotify API and SQLite improvements
This commit is contained in:
Chigozirim Igweamaka 2025-06-27 11:01:56 -07:00 committed by GitHub
commit e51b06c159
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 355 additions and 98 deletions

View file

@ -4,4 +4,6 @@ DB_PASS=password
DB_NAME=seek-tune
DB_HOST=192.168.0.1
DB_PORT=27017
REACT_APP_BACKEND_URL=http://localhost:5000
REACT_APP_BACKEND_URL=http://localhost:5000
SPOTIFY_CLIENT_ID=yourclientid
SPOTIFY_CLIENT_SECRET=yoursecret

View file

@ -41,6 +41,29 @@ Prerequisites: [Docker](https://docs.docker.com/get-docker/) and [Docker Compose
```Bash
docker-compose down
```
#### 🎧 Spotify API
To access Spotify metadata, the project now uses the official [Spotify Web API](https://developer.spotify.com/documentation/web-api/). This requires creating a developer application and retrieving a client ID and client secret.
Follow the [official getting started guide](https://developer.spotify.com/documentation/web-api/tutorials/getting-started#request-an-access-token) to:
1. Create a Spotify developer app.
2. Copy your **Client ID** and **Client Secret**.
##### Setting up Credentials
Instead of using a credentials.json file, the application now reads these values from environment variables.
Create a .env file in the server directory with the following content:
```
SPOTIFY_CLIENT_ID=your-client-id
SPOTIFY_CLIENT_SECRET=your-client-secret
```
Make sure this .env file is loaded into your environment before running the server.
The application will automatically read this file to fetch and cache access tokens. If the token is expired or missing, a new one will be requested.
#### 💻 Set Up Natively
Install dependencies for the backend
```
@ -77,6 +100,8 @@ go run *.go download <https://open.spotify.com/.../...>
go run *.go save [-f|--force] <path_to_song_file_or_dir_of_songs>
```
The `-f` or `--force` flag allows saving the song even if a YouTube ID is not found. Note that the frontend will not display matches without a YouTube ID.
Note: if `*.go` does not work try to use `./...` instead.
#### ▸ Find matches for a song/recording 🔎
```

View file

@ -15,6 +15,15 @@ type SQLiteClient struct {
}
func NewSQLiteClient(dataSourceName string) (*SQLiteClient, error) {
// Add busy timeout param to DSN (milliseconds)
if !strings.Contains(dataSourceName, "_busy_timeout") {
if strings.Contains(dataSourceName, "?") {
dataSourceName += "&_busy_timeout=5000" // 5 seconds
} else {
dataSourceName += "?_busy_timeout=5000"
}
}
db, err := sql.Open("sqlite3", dataSourceName)
if err != nil {
return nil, fmt.Errorf("error connecting to SQLite: %s", err)
@ -28,6 +37,7 @@ func NewSQLiteClient(dataSourceName string) (*SQLiteClient, error) {
return &SQLiteClient{db: db}, nil
}
// createTables creates the required tables if they don't exist
func createTables(db *sql.DB) error {
createSongsTable := `
@ -100,22 +110,26 @@ func (db *SQLiteClient) GetCouples(addresses []uint32) (map[uint32][]models.Coup
if err != nil {
return nil, fmt.Errorf("error querying database: %s", err)
}
defer rows.Close()
var docCouples []models.Couple
for rows.Next() {
var couple models.Couple
if err := rows.Scan(&couple.AnchorTimeMs, &couple.SongID); err != nil {
rows.Close() // close before returning error
return nil, fmt.Errorf("error scanning row: %s", err)
}
docCouples = append(docCouples, couple)
}
rows.Close() // close explicitly after reading
couples[address] = docCouples
}
return couples, nil
}
func (db *SQLiteClient) TotalSongs() (int, error) {
var count int
err := db.db.QueryRow("SELECT COUNT(*) FROM songs").Scan(&count)

View file

@ -1,14 +1,17 @@
module song-recognition
go 1.21.6
go 1.23.0
toolchain go1.24.3
require (
github.com/buger/jsonparser v1.1.1
github.com/fatih/color v1.16.0
github.com/googollee/go-socket.io v1.7.0
github.com/kkdai/youtube/v2 v2.10.1
github.com/kkdai/youtube/v2 v2.10.4
github.com/mattn/go-sqlite3 v1.14.22
github.com/mdobak/go-xerrors v0.3.1
github.com/stretchr/testify v1.9.0
github.com/stretchr/testify v1.10.0
github.com/tidwall/gjson v1.17.1
go.mongodb.org/mongo-driver v1.14.0
gonum.org/v1/gonum v0.14.0
@ -20,8 +23,8 @@ require (
cloud.google.com/go/compute/metadata v0.2.3 // indirect
github.com/bitly/go-simplejson v0.5.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dop251/goja v0.0.0-20240220182346-e401ed450204 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dop251/goja v0.0.0-20250125213203-5ef83b82af17 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@ -31,16 +34,16 @@ require (
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gomodule/redigo v1.8.4 // indirect
github.com/google/pprof v0.0.0-20240320155624-b11c3daa6f07 // indirect
github.com/google/pprof v0.0.0-20250208200701-d0013a598941 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.1 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/joho/godotenv v1.4.0 // indirect
github.com/klauspost/compress v1.17.6 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mjibson/go-dsp v0.0.0-20180508042940-11479a337f12 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
@ -55,12 +58,12 @@ require (
go.opentelemetry.io/otel v1.23.0 // indirect
go.opentelemetry.io/otel/metric v1.23.0 // indirect
go.opentelemetry.io/otel/trace v1.23.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/oauth2 v0.17.0 // indirect
golang.org/x/sync v0.6.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240213162025-012b6fc9bca9 // indirect
google.golang.org/grpc v1.61.1 // indirect

View file

@ -23,9 +23,13 @@ github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwu
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk=
github.com/dop251/goja v0.0.0-20240220182346-e401ed450204 h1:O7I1iuzEA7SG+dK8ocOBSlYAA9jBUmCYl/Qa7ey7JAM=
github.com/dop251/goja v0.0.0-20240220182346-e401ed450204/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=
github.com/dop251/goja v0.0.0-20250125213203-5ef83b82af17 h1:spJaibPy2sZNwo6Q0HjBVufq7hBUj5jNFOKRoogCBow=
github.com/dop251/goja v0.0.0-20250125213203-5ef83b82af17/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y=
github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@ -80,6 +84,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/google/pprof v0.0.0-20240320155624-b11c3daa6f07 h1:57oOH2Mu5Nw16KnZAVLdlUjmPH/TSYCKTJgG0OVfX0Y=
github.com/google/pprof v0.0.0-20240320155624-b11c3daa6f07/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/pprof v0.0.0-20250208200701-d0013a598941 h1:43XjGa6toxLpeksjcxs1jIoIyr+vUfOqY2c6HB4bpoc=
github.com/google/pprof v0.0.0-20250208200701-d0013a598941/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -94,8 +100,12 @@ github.com/googollee/go-socket.io v1.7.0/go.mod h1:0vGP8/dXR9SZUMMD4+xxaGo/lohOw
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kkdai/youtube/v2 v2.10.1 h1:jdPho4R7VxWoRi9Wx4ULMq4+hlzSVOXxh4Zh83f2F9M=
github.com/kkdai/youtube/v2 v2.10.1/go.mod h1:qL8JZv7Q1IoDs4nnaL51o/hmITXEIvyCIXopB0oqgVM=
github.com/kkdai/youtube/v2 v2.10.4 h1:T3VAQ65EB4eHptwcQIigpFvUJlV9EcKRGJJdSVUy3aU=
github.com/kkdai/youtube/v2 v2.10.4/go.mod h1:pm4RuJ2tRIIaOvz4YMIpCY8Ls4Fm7IVtnZQyule61MU=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@ -135,6 +145,7 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@ -168,6 +179,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
@ -186,6 +199,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ=
golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA=
@ -195,6 +210,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -208,6 +225,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -217,6 +236,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=

View file

@ -9,6 +9,7 @@ import (
"song-recognition/utils"
"github.com/mdobak/go-xerrors"
"github.com/joho/godotenv"
)
func main() {
@ -33,7 +34,8 @@ func main() {
fmt.Println("Expected 'find', 'download', 'erase', 'save', or 'serve' subcommands")
os.Exit(1)
}
_ = godotenv.Load()
switch os.Args[1] {
case "find":
if len(os.Args) < 3 {

View file

@ -28,16 +28,16 @@ const DELETE_SONG_FILE = false
var yellow = color.New(color.FgYellow)
func DlSingleTrack(url, savePath string) (int, error) {
logger := utils.GetLogger()
logger.Info("Getting track info", slog.String("url", url))
trackInfo, err := TrackInfo(url)
if err != nil {
return 0, err
}
fmt.Println("Getting track info...")
time.Sleep(500 * time.Millisecond)
track := []Track{*trackInfo}
fmt.Println("Now, downloading track...")
logger.Info("Now downloading track")
totalTracksDownloaded, err := dlTrack(track, savePath)
if err != nil {
return 0, err
@ -47,13 +47,14 @@ func DlSingleTrack(url, savePath string) (int, error) {
}
func DlPlaylist(url, savePath string) (int, error) {
logger := utils.GetLogger()
tracks, err := PlaylistInfo(url)
if err != nil {
return 0, err
}
time.Sleep(1 * time.Second)
fmt.Println("Now, downloading playlist...")
logger.Info("Now downloading playlist")
totalTracksDownloaded, err := dlTrack(tracks, savePath)
if err != nil {
return 0, err
@ -63,13 +64,14 @@ func DlPlaylist(url, savePath string) (int, error) {
}
func DlAlbum(url, savePath string) (int, error) {
logger := utils.GetLogger()
tracks, err := AlbumInfo(url)
if err != nil {
return 0, err
}
time.Sleep(1 * time.Second)
fmt.Println("Now, downloading album...")
logger.Info("Now downloading album")
totalTracksDownloaded, err := dlTrack(tracks, savePath)
if err != nil {
return 0, err
@ -119,7 +121,7 @@ func dlTrack(tracks []Track, path string) (int, error) {
logger.ErrorContext(ctx, "error checking song existence", slog.Any("error", err))
}
if keyExists {
logMessage := fmt.Sprintf("'%s' by '%s' already exits.", trackCopy.Title, trackCopy.Artist)
logMessage := fmt.Sprintf("'%s' by '%s' already exists.", trackCopy.Title, trackCopy.Artist)
logger.Info(logMessage)
return
}
@ -164,7 +166,7 @@ func dlTrack(tracks []Track, path string) (int, error) {
utils.DeleteFile(wavFilePath)
}
fmt.Printf("'%s' by '%s' was downloaded\n", track.Title, track.Artist)
logger.Info(fmt.Sprintf("'%s' by '%s' was downloaded", track.Title, track.Artist))
downloadedTracks = append(downloadedTracks, fmt.Sprintf("%s, %s", track.Title, track.Artist))
results <- 1
}(t)
@ -179,25 +181,30 @@ func dlTrack(tracks []Track, path string) (int, error) {
totalTracks++
}
fmt.Println("Total tracks downloaded:", totalTracks)
logger.Info(fmt.Sprintf("Total tracks downloaded: %d", totalTracks))
return totalTracks, nil
}
/* github.com/kkdai/youtube */
func downloadYTaudio(id, path, filePath string) error {
logger := utils.GetLogger()
dir, err := os.Stat(path)
if err != nil {
panic(err)
logger.Error("Error accessing path", slog.Any("error", err))
return err
}
if !dir.IsDir() {
return errors.New("the path is not valid (not a dir)")
err := errors.New("the path is not valid (not a dir)")
logger.Error("Invalid directory path", slog.Any("error", err))
return err
}
client := youtube.Client{}
video, err := client.GetVideo(id)
if err != nil {
logger.Error("Error getting YouTube video", slog.Any("error", err))
return err
}
@ -215,16 +222,19 @@ func downloadYTaudio(id, path, filePath string) error {
var fileSize int64
file, err := os.Create(filePath)
if err != nil {
logger.Error("Error creating file", slog.Any("error", err))
return err
}
for fileSize == 0 {
stream, _, err := client.GetStream(video, &formats[0])
if err != nil {
logger.Error("Error getting stream", slog.Any("error", err))
return err
}
if _, err = io.Copy(file, stream); err != nil {
logger.Error("Error copying stream to file", slog.Any("error", err))
return err
}
@ -236,6 +246,7 @@ func downloadYTaudio(id, path, filePath string) error {
}
func addTags(file string, track Track) error {
logger := utils.GetLogger()
// Create a temporary file name by appending "2" before the extension
tempFile := file
index := strings.Index(file, ".wav")
@ -258,11 +269,13 @@ func addTags(file string, track Track) error {
out, err := cmd.CombinedOutput()
if err != nil {
logger.Error("Failed to add tags", slog.Any("error", err), slog.String("output", string(out)))
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 {
logger.Error("Failed to rename file", slog.Any("error", err))
return fmt.Errorf("failed to rename file: %v", err)
}
@ -270,34 +283,41 @@ func addTags(file string, track Track) error {
}
func ProcessAndSaveSong(songFilePath, songTitle, songArtist, ytID string) error {
logger := utils.GetLogger()
dbclient, err := db.NewDBClient()
if err != nil {
logger.Error("Failed to create DB client", slog.Any("error", err))
return err
}
defer dbclient.Close()
wavFilePath, err := wav.ConvertToWAV(songFilePath, 1)
if err != nil {
logger.Error("Failed to convert to WAV", slog.Any("error", err))
return err
}
wavInfo, err := wav.ReadWavInfo(wavFilePath)
if err != nil {
logger.Error("Failed to read WAV info", slog.Any("error", err))
return err
}
samples, err := wav.WavBytesToSamples(wavInfo.Data)
if err != nil {
logger.Error("Error converting WAV bytes to samples", slog.Any("error", err))
return fmt.Errorf("error converting wav bytes to float64: %v", err)
}
spectro, err := shazam.Spectrogram(samples, wavInfo.SampleRate)
if err != nil {
logger.Error("Error creating spectrogram", slog.Any("error", err))
return fmt.Errorf("error creating spectrogram: %v", err)
}
songID, err := dbclient.RegisterSong(songTitle, songArtist, ytID)
if err != nil {
logger.Error("Failed to register song", slog.Any("error", err))
return err
}
@ -307,14 +327,16 @@ func ProcessAndSaveSong(songFilePath, songTitle, songArtist, ytID string) error
err = dbclient.StoreFingerprints(fingerprints)
if err != nil {
dbclient.DeleteSongByID(songID)
return fmt.Errorf("error to storing fingerprint: %v", err)
logger.Error("Failed to store fingerprints", slog.Any("error", err))
return fmt.Errorf("error storing fingerprint: %v", err)
}
fmt.Printf("Fingerprint for %v by %v saved in DB successfully\n", songTitle, songArtist)
logger.Info(fmt.Sprintf("Fingerprint for %v by %v saved in DB successfully", songTitle, songArtist))
return nil
}
func getYTID(trackCopy *Track) (string, error) {
logger := utils.GetLogger()
ytID, err := GetYoutubeId(*trackCopy)
if ytID == "" || err != nil {
return "", err
@ -327,9 +349,8 @@ func getYTID(trackCopy *Track) (string, error) {
}
if ytidExists { // try to get the YouTube ID again
logMessage := fmt.Sprintf("YouTube ID (%s) exists. Trying again...\n", ytID)
fmt.Println("WARN: ", logMessage)
slog.Warn(logMessage)
logMessage := fmt.Sprintf("YouTube ID (%s) exists. Trying again...", ytID)
logger.Warn(logMessage)
ytID, err = GetYoutubeId(*trackCopy)
if ytID == "" || err != nil {

View file

@ -1,14 +1,19 @@
package spotify
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"os"
"song-recognition/utils"
"github.com/tidwall/gjson"
)
@ -25,29 +30,113 @@ type Track struct {
}
const (
tokenEndpoint = "https://open.spotify.com/get_access_token?reason=transport&productType=web-player"
trackInitialPath = "https://api-partner.spotify.com/pathfinder/v1/query?operationName=getTrack&variables="
playlistInitialPath = "https://api-partner.spotify.com/pathfinder/v1/query?operationName=fetchPlaylist&variables="
albumInitialPath = "https://api-partner.spotify.com/pathfinder/v1/query?operationName=getAlbum&variables="
trackEndPath = `{"persistedQuery":{"version":1,"sha256Hash":"e101aead6d78faa11d75bec5e36385a07b2f1c4a0420932d374d89ee17c70dd6"}}`
playlistEndPath = `{"persistedQuery":{"version":1,"sha256Hash":"b39f62e9b566aa849b1780927de1450f47e02c54abf1e66e513f96e849591e41"}}`
albumEndPath = `{"persistedQuery":{"version":1,"sha256Hash":"46ae954ef2d2fe7732b4b2b4022157b2e18b7ea84f70591ceb164e4de1b5d5d3"}}`
tokenURL = "https://accounts.spotify.com/api/token"
cachedTokenPath = "token.json"
)
type credentials struct {
ClientID string
ClientSecret string
}
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
type cachedToken struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
func loadCredentials() (*credentials, error) {
clientID := utils.GetEnv("SPOTIFY_CLIENT_ID", "")
clientSecret := utils.GetEnv("SPOTIFY_CLIENT_SECRET", "")
if clientID == "" || clientSecret == "" {
return nil, fmt.Errorf("SPOTIFY_CLIENT_ID or SPOTIFY_CLIENT_SECRET environment variables not set")
}
return &credentials{
ClientID: clientID,
ClientSecret: clientSecret,
}, nil
}
func saveToken(token string, expiresIn int) error {
ct := cachedToken{
Token: token,
ExpiresAt: time.Now().Add(time.Duration(expiresIn) * time.Second),
}
data, err := json.MarshalIndent(ct, "", " ")
if err != nil {
return err
}
return os.WriteFile(cachedTokenPath, data, 0644)
}
func loadCachedToken() (string, error) {
data, err := os.ReadFile(cachedTokenPath)
if err != nil {
return "", err
}
var ct cachedToken
if err := json.Unmarshal(data, &ct); err != nil {
return "", err
}
if time.Now().After(ct.ExpiresAt) {
return "", errors.New("token expired")
}
return ct.Token, nil
}
func accessToken() (string, error) {
resp, err := http.Get(tokenEndpoint)
// Try using cached token
token, err := loadCachedToken()
if err == nil {
return token, nil
}
// Fallback: request a new token
creds, err := loadCredentials()
if err != nil {
return "", err
}
data := url.Values{}
data.Set("grant_type", "client_credentials")
data.Set("client_id", creds.ClientID)
data.Set("client_secret", creds.ClientSecret)
req, err := http.NewRequest("POST", tokenURL, bytes.NewBufferString(data.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", errors.New("token request failed (have a look at credentials.json): " + string(body))
}
var tr tokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return "", err
}
accessToken := gjson.Get(string(body), "accessToken")
return accessToken.String(), nil
if err := saveToken(tr.AccessToken, tr.ExpiresIn); err != nil {
return "", err
}
return tr.AccessToken, nil
}
/* requests to playlist/track endpoints */
@ -89,86 +178,165 @@ func isValidPattern(url, pattern string) bool {
}
func TrackInfo(url string) (*Track, error) {
trackPattern := `^https:\/\/open\.spotify\.com\/track\/[a-zA-Z0-9]{22}\?si=[a-zA-Z0-9]{16}$`
if !isValidPattern(url, trackPattern) {
return nil, errors.New("invalid track url")
re := regexp.MustCompile(`open\.spotify\.com\/(?:intl-.+\/)?track\/([a-zA-Z0-9]{22})(\?si=[a-zA-Z0-9]{16})?`)
matches := re.FindStringSubmatch(url)
if len(matches) <= 2 {
return nil, errors.New("invalid track URL")
}
id := matches[1]
id := getID(url)
endpointQuery := EncodeParam(fmt.Sprintf(`{"uri":"spotify:track:%s"}`, id))
endpoint := trackInitialPath + endpointQuery + "&extensions=" + EncodeParam(trackEndPath)
endpoint := fmt.Sprintf("https://api.spotify.com/v1/tracks/%s", id)
statusCode, jsonResponse, err := request(endpoint)
if err != nil {
return nil, fmt.Errorf("error on getting track info: %w", err)
return nil, fmt.Errorf("error getting track info: %w", err)
}
if statusCode != 200 {
return nil, fmt.Errorf("non-200 status code: %d", statusCode)
}
if statusCode != 200 {
return nil, fmt.Errorf("received non-200 status code: %d", statusCode)
var result struct {
Name string `json:"name"`
Duration int `json:"duration_ms"`
Album struct {
Name string `json:"name"`
} `json:"album"`
Artists []struct {
Name string `json:"name"`
} `json:"artists"`
}
if err := json.Unmarshal([]byte(jsonResponse), &result); err != nil {
return nil, err
}
var allArtists []string
if firstArtist := gjson.Get(jsonResponse, "data.trackUnion.firstArtist.items.0.profile.name").String(); firstArtist != "" {
allArtists = append(allArtists, firstArtist)
for _, a := range result.Artists {
allArtists = append(allArtists, a.Name)
}
if artists := gjson.Get(jsonResponse, "data.trackUnion.otherArtists.items").Array(); len(artists) > 0 {
for _, artist := range artists {
if profile := artist.Get("profile").Map(); len(profile) > 0 {
if name := profile["name"].String(); name != "" {
allArtists = append(allArtists, name)
}
return (&Track{
Title: result.Name,
Artist: allArtists[0],
Artists: allArtists,
Album: result.Album.Name,
Duration: result.Duration / 1000,
}).buildTrack(), nil
}
func PlaylistInfo(url string) ([]Track, error) {
re := regexp.MustCompile(`open\.spotify\.com\/playlist\/([a-zA-Z0-9]{22})`)
matches := re.FindStringSubmatch(url)
if len(matches) != 2 {
return nil, errors.New("invalid playlist URL")
}
id := matches[1]
var allTracks []Track
offset := 0
limit := 100
for {
endpoint := fmt.Sprintf("https://api.spotify.com/v1/playlists/%s/tracks?offset=%d&limit=%d", id, offset, limit)
statusCode, jsonResponse, err := request(endpoint)
if err != nil {
return nil, fmt.Errorf("request error: %w", err)
}
if statusCode != 200 {
return nil, fmt.Errorf("non-200 status: %d", statusCode)
}
var result struct {
Items []struct {
Track struct {
Name string `json:"name"`
Duration int `json:"duration_ms"`
Album struct {
Name string `json:"name"`
} `json:"album"`
Artists []struct {
Name string `json:"name"`
} `json:"artists"`
} `json:"track"`
} `json:"items"`
Total int `json:"total"`
}
if err := json.Unmarshal([]byte(jsonResponse), &result); err != nil {
return nil, err
}
for _, item := range result.Items {
track := item.Track
var artists []string
for _, a := range track.Artists {
artists = append(artists, a.Name)
}
allTracks = append(allTracks, *(&Track{
Title: track.Name,
Artist: artists[0],
Artists: artists,
Duration: track.Duration / 1000,
Album: track.Album.Name,
}).buildTrack())
}
offset += limit
if offset >= result.Total {
break
}
}
durationInSeconds := int(gjson.Get(jsonResponse, "data.trackUnion.duration.totalMilliseconds").Int())
durationInSeconds = durationInSeconds / 1000
track := &Track{
Title: gjson.Get(jsonResponse, "data.trackUnion.name").String(),
Artist: gjson.Get(jsonResponse, "data.trackUnion.firstArtist.items.0.profile.name").String(),
Artists: allArtists,
Duration: durationInSeconds,
Album: gjson.Get(jsonResponse, "data.trackUnion.albumOfTrack.name").String(),
}
return track.buildTrack(), nil
}
func PlaylistInfo(url string) ([]Track, error) {
playlistPattern := `^https:\/\/open\.spotify\.com\/playlist\/[a-zA-Z0-9]{22}\?si=[a-zA-Z0-9]{16}$`
if !isValidPattern(url, playlistPattern) {
return nil, errors.New("invalid playlist url")
}
totalCount := "data.playlistV2.content.totalCount"
itemsArray := "data.playlistV2.content.items"
tracks, err := resourceInfo(url, "playlist", totalCount, itemsArray)
if err != nil {
return nil, err
}
return tracks, nil
return allTracks, nil
}
func AlbumInfo(url string) ([]Track, error) {
albumPattern := `^https:\/\/open\.spotify\.com\/album\/[a-zA-Z0-9-]{22}\?si=[a-zA-Z0-9_-]{22}$`
if !isValidPattern(url, albumPattern) {
return nil, errors.New("invalid album url")
re := regexp.MustCompile(`open\.spotify\.com\/album\/([a-zA-Z0-9]{22})`)
matches := re.FindStringSubmatch(url)
if len(matches) != 2 {
return nil, errors.New("invalid album URL")
}
id := matches[1]
endpoint := fmt.Sprintf("https://api.spotify.com/v1/albums/%s/tracks?limit=50", id)
statusCode, jsonResponse, err := request(endpoint)
if err != nil {
return nil, fmt.Errorf("error getting album info: %w", err)
}
if statusCode != 200 {
return nil, fmt.Errorf("non-200 status: %d", statusCode)
}
totalCount := "data.albumUnion.discs.items.0.tracks.totalCount"
itemsArray := "data.albumUnion.discs.items"
tracks, err := resourceInfo(url, "album", totalCount, itemsArray)
if err != nil {
var result struct {
Items []struct {
Name string `json:"name"`
Duration int `json:"duration_ms"`
Artists []struct {
Name string `json:"name"`
} `json:"artists"`
} `json:"items"`
}
if err := json.Unmarshal([]byte(jsonResponse), &result); err != nil {
return nil, err
}
var tracks []Track
for _, item := range result.Items {
var artists []string
for _, a := range item.Artists {
artists = append(artists, a.Name)
}
tracks = append(tracks, *(&Track{
Title: item.Name,
Artist: artists[0],
Artists: artists,
Duration: item.Duration / 1000,
Album: "", // You can fetch full album info if needed
}).buildTrack())
}
return tracks, nil
}
/* returns playlist/album slice of tracks */
func resourceInfo(url, resourceType, totalCount, itemList string) ([]Track, error) {
id := getID(url)
@ -212,10 +380,10 @@ func jsonList(resourceType, id string, offset, limit int64) (string, error) {
var endpoint string
if resourceType == "playlist" {
endpointQuery = EncodeParam(fmt.Sprintf(`{"uri":"spotify:playlist:%s","offset":%d,"limit":%d}`, id, offset, limit))
endpoint = playlistInitialPath + endpointQuery + "&extensions=" + EncodeParam(playlistEndPath)
endpoint = endpointQuery
} else {
endpointQuery = EncodeParam(fmt.Sprintf(`{"uri":"spotify:album:%s","locale":"","offset":%d,"limit":%d}`, id, offset, limit))
endpoint = albumInitialPath + endpointQuery + "&extensions=" + EncodeParam(albumEndPath)
endpoint = endpointQuery
}
statusCode, jsonResponse, err := request(endpoint)

View file

@ -96,6 +96,7 @@ func GetYoutubeId(track Track) (string, error) {
allowedDurationRangeEnd := songDurationInSeconds + durationMatchThreshold
resultSongDuration := convertStringDurationToSeconds(result.Duration)
if resultSongDuration >= allowedDurationRangeStart && resultSongDuration <= allowedDurationRangeEnd {
fmt.Println("INFO: ", fmt.Sprintf("Found song with id '%s'", result.ID))
return result.ID, nil
}
}