mirror of
https://github.com/cgzirim/seek-tune.git
synced 2025-12-16 16:34:21 +00:00
Merge pull request #40 from KaNaDaAT/main
Spotify API and SQLite improvements
This commit is contained in:
commit
e51b06c159
9 changed files with 355 additions and 98 deletions
|
|
@ -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
|
||||
25
README.md
25
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 <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 🔎
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue