From cc1cbe14b576d93820c69e9516b059dca42bd95d Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 14 Nov 2024 16:16:35 +0100 Subject: [PATCH] feat: add spotify integration --- config/development.toml | 4 + .../20241114135818_add_spotify_table.sql | 16 ++ db/queries/spotify.sql | 33 ++++ internal/api/api.go | 5 +- internal/api/message/message.go | 10 +- internal/api/spotify/spotify.go | 56 ++++++ internal/cmd/api.go | 5 +- internal/cmd/root.go | 10 +- internal/pkg/db/dto/spotify.go | 39 ++++ internal/pkg/db/sqlc/models.go | 9 + internal/pkg/db/sqlc/spotify.sql.go | 169 ++++++++++++++++++ internal/pkg/spotify/account.go | 47 +++++ internal/pkg/spotify/api.go | 45 +++++ internal/pkg/spotify/spotify.go | 84 +++++++++ pkg/util/slice.go | 11 ++ 15 files changed, 533 insertions(+), 10 deletions(-) create mode 100644 db/migrations/20241114135818_add_spotify_table.sql create mode 100644 db/queries/spotify.sql create mode 100644 internal/api/spotify/spotify.go create mode 100644 internal/pkg/db/dto/spotify.go create mode 100644 internal/pkg/db/sqlc/spotify.sql.go create mode 100644 internal/pkg/spotify/account.go create mode 100644 internal/pkg/spotify/api.go create mode 100644 internal/pkg/spotify/spotify.go diff --git a/config/development.toml b/config/development.toml index 33b3547..ebb26a0 100644 --- a/config/development.toml +++ b/config/development.toml @@ -2,6 +2,10 @@ host = "localhost" port = 3000 +[spotify] +client_id = "your_client_id" +client_secret = "your_client_secret" + [buzzer] song = [ "-n", "-f880", "-l100", "-d0", diff --git a/db/migrations/20241114135818_add_spotify_table.sql b/db/migrations/20241114135818_add_spotify_table.sql new file mode 100644 index 0000000..acbee16 --- /dev/null +++ b/db/migrations/20241114135818_add_spotify_table.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS spotify ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + artists TEXT NOT NULL, + spotify_id TEXT NOT NULL, + duration_ms INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS spotify; +-- +goose StatementEnd diff --git a/db/queries/spotify.sql b/db/queries/spotify.sql new file mode 100644 index 0000000..2074db9 --- /dev/null +++ b/db/queries/spotify.sql @@ -0,0 +1,33 @@ +-- CRUD + +-- name: GetAllSpotify :many +SELECT * +FROM spotify; + +-- name: GetSpotifyByID :one +SELECT * +FROM spotify +WHERE id = ?; + +-- name: CreateSpotify :one +INSERT INTO spotify (title, artists, spotify_id, duration_ms) +VALUES (?, ?, ?, ?) +RETURNING *; + +-- name: UpdateSpotify :one +UPDATE spotify +SET title = ?, artists = ?, spotify_id = ?, duration_ms = ? +WHERE id = ? +RETURNING *; + +-- name: DeleteSpotify :execrows +DELETE FROM spotify +WHERE id = ?; + + +-- Other + +-- name: GetSpotifyBySpotifyID :one +SELECT * +FROM spotify +WHERE spotify_id = ?; diff --git a/internal/api/api.go b/internal/api/api.go index e51fdf3..e78b6cd 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -4,10 +4,13 @@ package api import ( "github.com/gofiber/fiber/v2" "github.com/zeusWPI/scc/internal/api/message" + apiSpotify "github.com/zeusWPI/scc/internal/api/spotify" "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/pkg/spotify" ) // New creates a new API instance -func New(router fiber.Router, db *db.DB) { +func New(router fiber.Router, db *db.DB, spotify *spotify.Spotify) { message.New(router, db) + apiSpotify.New(router, db, spotify) } diff --git a/internal/api/message/message.go b/internal/api/message/message.go index cd57b47..9861569 100644 --- a/internal/api/message/message.go +++ b/internal/api/message/message.go @@ -34,7 +34,7 @@ func (r *Router) createRoutes() { func (r *Router) getAll(c *fiber.Ctx) error { messages, err := r.db.Queries.GetAllMessages(c.Context()) if err != nil { - zap.S().Error("DB: Get all messages", err) + zap.S().Error("DB: Get all messages\n", err) return c.SendStatus(fiber.StatusInternalServerError) } @@ -45,20 +45,20 @@ func (r *Router) create(c *fiber.Ctx) error { message := new(dto.Message) if err := c.BodyParser(message); err != nil { - zap.S().Error("Body parser", err) + zap.S().Error("API: Message body parser\n", err) return c.SendStatus(fiber.StatusBadRequest) } if err := dto.Validate.Struct(message); err != nil { - zap.S().Error("Validation", err) + zap.S().Error("API: Message validation\n", err) return c.SendStatus(fiber.StatusBadRequest) } messageDB, err := r.db.Queries.CreateMessage(c.Context(), message.CreateParams()) if err != nil { - zap.S().Error("DB: Create message", err) + zap.S().Error("DB: Create message\n", err) return c.SendStatus(fiber.StatusInternalServerError) } - return c.Status(fiber.StatusOK).JSON(dto.MessageDTO(messageDB)) + return c.Status(fiber.StatusCreated).JSON(dto.MessageDTO(messageDB)) } diff --git a/internal/api/spotify/spotify.go b/internal/api/spotify/spotify.go new file mode 100644 index 0000000..c9a0bd4 --- /dev/null +++ b/internal/api/spotify/spotify.go @@ -0,0 +1,56 @@ +// Package spotify provides the API regarding spotify integration +package spotify + +import ( + "github.com/gofiber/fiber/v2" + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/pkg/db/dto" + "github.com/zeusWPI/scc/internal/pkg/spotify" + "go.uber.org/zap" +) + +// Router is the spotify API router +type Router struct { + router fiber.Router + db *db.DB + spotify *spotify.Spotify +} + +// New creates a new spotify API instance +func New(router fiber.Router, db *db.DB, spotify *spotify.Spotify) *Router { + api := &Router{ + router: router.Group("/spotify"), + db: db, + spotify: spotify, + } + api.createRoutes() + + return api +} + +func (r *Router) createRoutes() { + r.router.Post("/", r.new) +} + +func (r *Router) new(c *fiber.Ctx) error { + spotify := new(dto.Spotify) + + if err := c.BodyParser(spotify); err != nil { + zap.S().Error("API: Spotify body parser\n", err) + return c.SendStatus(fiber.StatusBadRequest) + } + + if err := dto.Validate.Struct(spotify); err != nil { + zap.S().Error("API: Spotify validation\n", err) + return c.SendStatus(fiber.StatusBadRequest) + } + + go func() { + err := r.spotify.Track(spotify) + if err != nil { + zap.S().Error("Spotify: Get Track\n", err) + } + }() + + return c.SendStatus(fiber.StatusOK) +} diff --git a/internal/cmd/api.go b/internal/cmd/api.go index 1e04757..0e76d3d 100644 --- a/internal/cmd/api.go +++ b/internal/cmd/api.go @@ -8,11 +8,12 @@ import ( "github.com/gofiber/fiber/v2/middleware/cors" "github.com/zeusWPI/scc/internal/api" "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/pkg/spotify" "github.com/zeusWPI/scc/pkg/config" "go.uber.org/zap" ) -func apiCmd(db *db.DB) { +func apiCmd(db *db.DB, spotify *spotify.Spotify) { app := fiber.New(fiber.Config{ BodyLimit: 1024 * 1024 * 1024, }) @@ -27,7 +28,7 @@ func apiCmd(db *db.DB) { ) apiGroup := app.Group("/api") - api.New(apiGroup, db) + api.New(apiGroup, db, spotify) host := config.GetDefaultString("server.host", "127.0.0.1") port := config.GetDefaultInt("server.port", 3000) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 9c42cda..70b8030 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -3,6 +3,7 @@ package cmd import ( "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/pkg/spotify" "github.com/zeusWPI/scc/pkg/config" "github.com/zeusWPI/scc/pkg/logger" "go.uber.org/zap" @@ -23,8 +24,13 @@ func Execute() { db, err := db.New() if err != nil { - zap.S().Fatal("DB: Fatal error", err) + zap.S().Fatal("DB: Fatal error\n", err) } - apiCmd(db) + spotify, err := spotify.New(db) + if err != nil { + zap.S().Error("Spotify: Initiating error, integration will not work.\n", err) + } + + apiCmd(db, spotify) } diff --git a/internal/pkg/db/dto/spotify.go b/internal/pkg/db/dto/spotify.go new file mode 100644 index 0000000..be4cbb5 --- /dev/null +++ b/internal/pkg/db/dto/spotify.go @@ -0,0 +1,39 @@ +package dto + +import ( + "time" + + "github.com/zeusWPI/scc/internal/pkg/db/sqlc" +) + +// Spotify is the DTO for the spotify +type Spotify struct { + ID int64 `json:"id"` + Title string `json:"title"` + Artists string `json:"artists"` + SpotifyID string `json:"spotify_id" validate:"required"` + DurationMS int64 `json:"duration_ms"` + CreatedAt time.Time `json:"created_at"` +} + +// SpotifyDTO converts a sqlc.Spotify to a Spotify +func SpotifyDTO(spotify sqlc.Spotify) *Spotify { + return &Spotify{ + ID: spotify.ID, + Title: spotify.Title, + Artists: spotify.Artists, + SpotifyID: spotify.SpotifyID, + DurationMS: spotify.DurationMs, + CreatedAt: spotify.CreatedAt, + } +} + +// CreateParams converts a Spotify to sqlc.CreateSpotifyParams +func (s *Spotify) CreateParams() sqlc.CreateSpotifyParams { + return sqlc.CreateSpotifyParams{ + Title: s.Title, + Artists: s.Artists, + SpotifyID: s.SpotifyID, + DurationMs: s.DurationMS, + } +} diff --git a/internal/pkg/db/sqlc/models.go b/internal/pkg/db/sqlc/models.go index 01fdc23..5097850 100644 --- a/internal/pkg/db/sqlc/models.go +++ b/internal/pkg/db/sqlc/models.go @@ -15,3 +15,12 @@ type Message struct { Message string CreatedAt time.Time } + +type Spotify struct { + ID int64 + Title string + Artists string + SpotifyID string + DurationMs int64 + CreatedAt time.Time +} diff --git a/internal/pkg/db/sqlc/spotify.sql.go b/internal/pkg/db/sqlc/spotify.sql.go new file mode 100644 index 0000000..c79eeb6 --- /dev/null +++ b/internal/pkg/db/sqlc/spotify.sql.go @@ -0,0 +1,169 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: spotify.sql + +package sqlc + +import ( + "context" +) + +const createSpotify = `-- name: CreateSpotify :one +INSERT INTO spotify (title, artists, spotify_id, duration_ms) +VALUES (?, ?, ?, ?) +RETURNING id, title, artists, spotify_id, duration_ms, created_at +` + +type CreateSpotifyParams struct { + Title string + Artists string + SpotifyID string + DurationMs int64 +} + +func (q *Queries) CreateSpotify(ctx context.Context, arg CreateSpotifyParams) (Spotify, error) { + row := q.db.QueryRowContext(ctx, createSpotify, + arg.Title, + arg.Artists, + arg.SpotifyID, + arg.DurationMs, + ) + var i Spotify + err := row.Scan( + &i.ID, + &i.Title, + &i.Artists, + &i.SpotifyID, + &i.DurationMs, + &i.CreatedAt, + ) + return i, err +} + +const deleteSpotify = `-- name: DeleteSpotify :execrows +DELETE FROM spotify +WHERE id = ? +` + +func (q *Queries) DeleteSpotify(ctx context.Context, id int64) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteSpotify, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getAllSpotify = `-- name: GetAllSpotify :many + +SELECT id, title, artists, spotify_id, duration_ms, created_at +FROM spotify +` + +// CRUD +func (q *Queries) GetAllSpotify(ctx context.Context) ([]Spotify, error) { + rows, err := q.db.QueryContext(ctx, getAllSpotify) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Spotify + for rows.Next() { + var i Spotify + if err := rows.Scan( + &i.ID, + &i.Title, + &i.Artists, + &i.SpotifyID, + &i.DurationMs, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getSpotifyByID = `-- name: GetSpotifyByID :one +SELECT id, title, artists, spotify_id, duration_ms, created_at +FROM spotify +WHERE id = ? +` + +func (q *Queries) GetSpotifyByID(ctx context.Context, id int64) (Spotify, error) { + row := q.db.QueryRowContext(ctx, getSpotifyByID, id) + var i Spotify + err := row.Scan( + &i.ID, + &i.Title, + &i.Artists, + &i.SpotifyID, + &i.DurationMs, + &i.CreatedAt, + ) + return i, err +} + +const getSpotifyBySpotifyID = `-- name: GetSpotifyBySpotifyID :one + +SELECT id, title, artists, spotify_id, duration_ms, created_at +FROM spotify +WHERE spotify_id = ? +` + +// Other +func (q *Queries) GetSpotifyBySpotifyID(ctx context.Context, spotifyID string) (Spotify, error) { + row := q.db.QueryRowContext(ctx, getSpotifyBySpotifyID, spotifyID) + var i Spotify + err := row.Scan( + &i.ID, + &i.Title, + &i.Artists, + &i.SpotifyID, + &i.DurationMs, + &i.CreatedAt, + ) + return i, err +} + +const updateSpotify = `-- name: UpdateSpotify :one +UPDATE spotify +SET title = ?, artists = ?, spotify_id = ?, duration_ms = ? +WHERE id = ? +RETURNING id, title, artists, spotify_id, duration_ms, created_at +` + +type UpdateSpotifyParams struct { + Title string + Artists string + SpotifyID string + DurationMs int64 + ID int64 +} + +func (q *Queries) UpdateSpotify(ctx context.Context, arg UpdateSpotifyParams) (Spotify, error) { + row := q.db.QueryRowContext(ctx, updateSpotify, + arg.Title, + arg.Artists, + arg.SpotifyID, + arg.DurationMs, + arg.ID, + ) + var i Spotify + err := row.Scan( + &i.ID, + &i.Title, + &i.Artists, + &i.SpotifyID, + &i.DurationMs, + &i.CreatedAt, + ) + return i, err +} diff --git a/internal/pkg/spotify/account.go b/internal/pkg/spotify/account.go new file mode 100644 index 0000000..816c869 --- /dev/null +++ b/internal/pkg/spotify/account.go @@ -0,0 +1,47 @@ +package spotify + +import ( + "encoding/json" + "errors" + "time" + + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) + +const accountURL = "https://accounts.spotify.com/api/token" + +type accountResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` +} + +func (s *Spotify) refreshToken() error { + zap.S().Info("Spotify: Refreshing access token") + + body, err := json.Marshal(fiber.Map{ + "grant_type": "client_credentials", + "client_id": s.ClientID, + "client_secret": s.ClientSecret, + }) + if err != nil { + return err + } + + req := fiber.Post(accountURL).Body(body).ContentType("application/json") + + res := new(accountResponse) + status, _, errs := req.Struct(res) + if len(errs) > 0 { + return errors.Join(errs...) + } + if status != fiber.StatusOK { + return errors.New("error getting access token") + } + + s.AccessToken = res.AccessToken + s.ExpiresTime = time.Now().Unix() + res.ExpiresIn + + return nil +} diff --git a/internal/pkg/spotify/api.go b/internal/pkg/spotify/api.go new file mode 100644 index 0000000..2715587 --- /dev/null +++ b/internal/pkg/spotify/api.go @@ -0,0 +1,45 @@ +package spotify + +import ( + "errors" + "fmt" + + "github.com/gofiber/fiber/v2" + "github.com/zeusWPI/scc/internal/pkg/db/dto" + "github.com/zeusWPI/scc/pkg/util" + "go.uber.org/zap" +) + +const apiURL = "https://api.spotify.com/v1" + +type trackArtist struct { + Name string `json:"name"` +} + +type trackResponse struct { + Name string `json:"name"` + Artists []trackArtist `json:"artists"` + DurationMS int64 `json:"duration_ms"` +} + +func (s *Spotify) setTrack(track *dto.Spotify) error { + zap.S().Info("Spotify: Getting track info for id: ", track.SpotifyID) + + req := fiber.Get(fmt.Sprintf("%s/%s/%s", apiURL, "tracks", track.SpotifyID)). + Set("Authorization", fmt.Sprintf("Bearer %s", s.AccessToken)) + + res := new(trackResponse) + status, _, errs := req.Struct(res) + if len(errs) > 0 { + return errors.Join(errs...) + } + if status != fiber.StatusOK { + return errors.New("error getting track") + } + + track.Title = res.Name + track.Artists = util.SliceStringJoin(res.Artists, ", ", func(a trackArtist) string { return a.Name }) + track.DurationMS = res.DurationMS + + return nil +} diff --git a/internal/pkg/spotify/spotify.go b/internal/pkg/spotify/spotify.go new file mode 100644 index 0000000..4cc4b33 --- /dev/null +++ b/internal/pkg/spotify/spotify.go @@ -0,0 +1,84 @@ +// Package spotify provides all spotify related logic +package spotify + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/zeusWPI/scc/internal/pkg/db" + "github.com/zeusWPI/scc/internal/pkg/db/dto" + "github.com/zeusWPI/scc/internal/pkg/db/sqlc" + "github.com/zeusWPI/scc/pkg/config" +) + +// Spotify represents a spotify instance +type Spotify struct { + db *db.DB + ClientID string + ClientSecret string + AccessToken string + ExpiresTime int64 +} + +// New creates a new spotify instance +func New(db *db.DB) (*Spotify, error) { + clientID := config.GetDefaultString("spotify.client_id", "") + clientSecret := config.GetDefaultString("spotify.client_secret", "") + + if clientID == "" || clientSecret == "" { + return &Spotify{}, errors.New("Spotify client id or secret not set") + } + + return &Spotify{db: db, ClientID: clientID, ClientSecret: clientSecret, ExpiresTime: 0}, nil +} + +// Track gets information about the current track and stores it in the database +func (s *Spotify) Track(track *dto.Spotify) error { + if s.ClientID == "" || s.ClientSecret == "" { + return errors.New("spotify client id or secret not set") + } + + // Check if song is already in DB + trackDB, err := s.db.Queries.GetSpotifyBySpotifyID(context.Background(), track.SpotifyID) + if err != nil { + if err != sql.ErrNoRows { + return err + } + } + + if (trackDB != sqlc.Spotify{}) { + // Already in DB + // No need to refetch data + track.Title = trackDB.Title + track.Artists = trackDB.Artists + track.DurationMS = trackDB.DurationMs + _, err := s.db.Queries.CreateSpotify(context.Background(), track.CreateParams()) + + return err + } + + // Refresh token if needed + if s.ExpiresTime <= time.Now().Unix() { + err := s.refreshToken() + if err != nil { + return err + } + } + + // Set track info + err = s.setTrack(track) + if err != nil { + return err + } + + // Store track in DB + _, err = s.db.Queries.CreateSpotify(context.Background(), track.CreateParams()) + if err != nil { + return err + } + + return nil + +} diff --git a/pkg/util/slice.go b/pkg/util/slice.go index 2c50209..5e10700 100644 --- a/pkg/util/slice.go +++ b/pkg/util/slice.go @@ -1,6 +1,8 @@ // Package util provides utility functions package util +import "strings" + // SliceMap maps a slice of type T to a slice of type U func SliceMap[T any, U any](input []T, mapFunc func(T) U) []U { v := make([]U, len(input)) @@ -9,3 +11,12 @@ func SliceMap[T any, U any](input []T, mapFunc func(T) U) []U { } return v } + +// SliceStringJoin joins a slice of type T to a string with a separator +func SliceStringJoin[T any](input []T, sep string, mapFunc func(T) string) string { + v := make([]string, len(input)) + for i, item := range input { + v[i] = mapFunc(item) + } + return strings.Join(v, sep) +}