diff --git a/.env.example b/.env.example index bc65741..e30fdc5 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file +REACT_APP_BACKEND_URL=http://localhost:5000 +SPOTIFY_CLIENT_ID=yourclientid +SPOTIFY_CLIENT_SECRET=yoursecret \ No newline at end of file diff --git a/README.md b/README.md index 0c257f6..3c66eb4 100644 --- a/README.md +++ b/README.md @@ -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 go run *.go save [-f|--force] ``` 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 🔎 ``` diff --git a/server/db/sqlite.go b/server/db/sqlite.go index 03691a6..6cffcf7 100644 --- a/server/db/sqlite.go +++ b/server/db/sqlite.go @@ -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) diff --git a/server/go.mod b/server/go.mod index 7478c63..fd5a569 100644 --- a/server/go.mod +++ b/server/go.mod @@ -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 diff --git a/server/go.sum b/server/go.sum index 0dd0434..5aa3bf8 100644 --- a/server/go.sum +++ b/server/go.sum @@ -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= diff --git a/server/main.go b/server/main.go index 7836995..5fba59c 100644 --- a/server/main.go +++ b/server/main.go @@ -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 { diff --git a/server/spotify/downloader.go b/server/spotify/downloader.go index 1e32841..b9a95fb 100644 --- a/server/spotify/downloader.go +++ b/server/spotify/downloader.go @@ -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 { diff --git a/server/spotify/spotify.go b/server/spotify/spotify.go index 4729b27..096558a 100644 --- a/server/spotify/spotify.go +++ b/server/spotify/spotify.go @@ -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) diff --git a/server/spotify/youtube.go b/server/spotify/youtube.go index b1d4e7a..51d5d04 100644 --- a/server/spotify/youtube.go +++ b/server/spotify/youtube.go @@ -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 } }