From 16531f9fa11427fa1e3b9cf98b4d7337fb15f411 Mon Sep 17 00:00:00 2001 From: Daniel Mason Date: Sun, 28 Mar 2021 21:52:34 +1300 Subject: [PATCH] Scrobbles work! --- .env.example | 2 + README.md | 2 + docs/changelog.md | 3 + internal/goscrobble/album.go | 85 ++++++++++++++++++++ internal/goscrobble/artist.go | 72 +++++++++++++++++ internal/goscrobble/db.go | 8 +- internal/goscrobble/jellyfin.go | 48 +++++++++++- internal/goscrobble/scrobble.go | 50 ++++++++++++ internal/goscrobble/server.go | 60 ++++++++++---- internal/goscrobble/tokens.go | 25 ++++++ internal/goscrobble/track.go | 112 +++++++++++++++++++++++++++ internal/goscrobble/user.go | 7 +- internal/goscrobble/utils.go | 8 +- migrations/3_tracks.up.sql | 36 +++++---- migrations/4_mbid.down.sql | 7 ++ migrations/5_apitoken.down.sql | 1 + migrations/5_apitoken.up.sql | 1 + test/jellyfin_test.go | 14 ---- web/package-lock.json | 21 +++++ web/package.json | 1 + web/src/App.js | 6 +- web/src/Components/Navigation.js | 3 +- web/src/Components/Pages/Help.css | 4 + web/src/Components/Pages/Help.js | 17 ++++ web/src/Components/Pages/Login.js | 12 ++- web/src/Components/Pages/Register.js | 22 ++++-- web/src/index.js | 2 +- 27 files changed, 568 insertions(+), 61 deletions(-) create mode 100644 docs/changelog.md create mode 100644 internal/goscrobble/album.go create mode 100644 internal/goscrobble/artist.go create mode 100644 internal/goscrobble/scrobble.go create mode 100644 internal/goscrobble/tokens.go create mode 100644 internal/goscrobble/track.go create mode 100644 migrations/4_mbid.down.sql create mode 100644 migrations/5_apitoken.down.sql create mode 100644 migrations/5_apitoken.up.sql delete mode 100644 test/jellyfin_test.go create mode 100644 web/src/Components/Pages/Help.css create mode 100644 web/src/Components/Pages/Help.js diff --git a/.env.example b/.env.example index 64c5ad79..b75930d0 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,8 @@ REDIS_DB= REDIS_PREFIX="gs:" REDIS_AUTH="" +TIMEZONE=Etc/UTC + JWT_SECRET= JWT_EXPIRY=86400 diff --git a/README.md b/README.md index dfac77d7..ca2f2a76 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ There are prebuilt binaries/packages available. Copy .env.example to .env and set variables. You can use https://www.grc.com/passwords.htm to generate a JWT_SECRET. ## More documentation +[Changelog](docs/changelog.md) + [Environment Variables](docs/config.md) ## Setup MySQL diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 00000000..eb96f457 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,3 @@ +# 0.0.1 +- Initial commit +- Added basic registration/login flow diff --git a/internal/goscrobble/album.go b/internal/goscrobble/album.go new file mode 100644 index 00000000..de3c0cac --- /dev/null +++ b/internal/goscrobble/album.go @@ -0,0 +1,85 @@ +package goscrobble + +import ( + "database/sql" + "errors" + "log" +) + +type Album struct { + Uuid string `json:"uuid"` + Name string `json:"name"` + Desc sql.NullString `json:"desc"` + Img sql.NullString `json:"img"` + MusicBrainzID sql.NullString `json:"mbid"` +} + +// insertAlbum - This will return if it exists or create it based on MBID > Name +func insertAlbum(name string, mbid string, artists []string, tx *sql.Tx) (Album, error) { + album := Album{} + + if mbid != "" { + album = fetchAlbum("mbid", mbid, tx) + if album.Uuid == "" { + err := insertNewAlbum(name, mbid, tx) + if err != nil { + log.Printf("Error inserting album via MBID %s %+v", name, err) + return album, errors.New("Failed to insert album") + } + + album = fetchAlbum("mbid", mbid, tx) + } + } else { + album = fetchAlbum("name", name, tx) + if album.Uuid == "" { + err := insertNewAlbum(name, mbid, tx) + if err != nil { + log.Printf("Error inserting album via Name %s %+v", name, err) + return album, errors.New("Failed to insert album") + } + + album = fetchAlbum("name", name, tx) + } + } + + if album.Uuid == "" { + return album, errors.New("Unable to fetch album!") + } + + return album, nil +} + +func fetchAlbum(col string, val string, tx *sql.Tx) Album { + var album Album + err := tx.QueryRow( + "SELECT BIN_TO_UUID(`uuid`, true), `name`, `desc`, `img`, `mbid` FROM `albums` WHERE `"+col+"` = ?", + val).Scan(&album.Uuid, &album.Name, &album.Desc, &album.Img, &album.MusicBrainzID) + + if err != nil { + if err != sql.ErrNoRows { + log.Printf("Error fetching albums: %+v", err) + } + } + + return album +} + +func insertNewAlbum(name string, mbid string, tx *sql.Tx) error { + _, err := tx.Exec("INSERT INTO `albums` (`uuid`, `name`, `mbid`) "+ + "VALUES (UUID_TO_BIN(UUID(), true),?,?)", name, mbid) + + return err +} + +func (album *Album) linkAlbumToArtists(artists []string, tx *sql.Tx) error { + var err error + for _, artist := range artists { + _, err = tx.Exec("INSERT INTO `track_artist` (`track`, `artist`) "+ + "VALUES (UUID_TO_BIN(?, true), UUID_TO_BIN(?, true)", album.Uuid, artist) + if err != nil { + return err + } + } + + return err +} diff --git a/internal/goscrobble/artist.go b/internal/goscrobble/artist.go new file mode 100644 index 00000000..6058311d --- /dev/null +++ b/internal/goscrobble/artist.go @@ -0,0 +1,72 @@ +package goscrobble + +import ( + "database/sql" + "errors" + "log" +) + +type Artist struct { + Uuid string `json:"uuid"` + Name string `json:"name"` + Desc sql.NullString `json:"desc"` + Img sql.NullString `json:"img"` + MusicBrainzID sql.NullString `json:"mbid"` +} + +// insertArtist - This will return if it exists or create it based on MBID > Name +func insertArtist(name string, mbid string, tx *sql.Tx) (Artist, error) { + artist := Artist{} + + if mbid != "" { + artist = fetchArtist("mbid", mbid, tx) + if artist.Uuid == "" { + err := insertNewArtist(name, mbid, tx) + if err != nil { + log.Printf("Error inserting artist via MBID %s %+v", name, err) + return artist, errors.New("Failed to insert artist") + } + + artist = fetchArtist("mbid", mbid, tx) + } + } else { + artist = fetchArtist("name", name, tx) + if artist.Uuid == "" { + err := insertNewArtist(name, mbid, tx) + if err != nil { + log.Printf("Error inserting artist via Name %s %+v", name, err) + return artist, errors.New("Failed to insert artist") + } + + artist = fetchArtist("name", name, tx) + } + } + + if artist.Uuid == "" { + return artist, errors.New("Unable to fetch artist!") + } + + return artist, nil +} + +func fetchArtist(col string, val string, tx *sql.Tx) Artist { + var artist Artist + err := tx.QueryRow( + "SELECT BIN_TO_UUID(`uuid`, true), `name`, `desc`, `img`, `mbid` FROM `artists` WHERE `"+col+"` = ?", + val).Scan(&artist.Uuid, &artist.Name, &artist.Desc, &artist.Img, &artist.MusicBrainzID) + + if err != nil { + if err != sql.ErrNoRows { + log.Printf("Error fetching artists: %+v", err) + } + } + + return artist +} + +func insertNewArtist(name string, mbid string, tx *sql.Tx) error { + _, err := tx.Exec("INSERT INTO `artists` (`uuid`, `name`, `mbid`) "+ + "VALUES (UUID_TO_BIN(UUID(), true),?,?)", name, mbid) + + return err +} diff --git a/internal/goscrobble/db.go b/internal/goscrobble/db.go index d3b7f9a0..210a169f 100644 --- a/internal/goscrobble/db.go +++ b/internal/goscrobble/db.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "os" + "strings" "time" _ "github.com/go-sql-driver/mysql" @@ -22,8 +23,13 @@ func InitDb() { dbUser := os.Getenv("MYSQL_USER") dbPass := os.Getenv("MYSQL_PASS") dbName := os.Getenv("MYSQL_DB") + timeZone := os.Getenv("TIMEZONE") + dbTz := "" + if timeZone != "" { + dbTz = "&loc=" + strings.Replace(timeZone, "/", fmt.Sprintf("%%2F"), 1) + } - dbConn, err := sql.Open("mysql", dbUser+":"+dbPass+"@tcp("+dbHost+")/"+dbName+"?multiStatements=true") + dbConn, err := sql.Open("mysql", dbUser+":"+dbPass+"@tcp("+dbHost+")/"+dbName+"?multiStatements=true"+dbTz) if err != nil { panic(err) } diff --git a/internal/goscrobble/jellyfin.go b/internal/goscrobble/jellyfin.go index a660d11c..154beac7 100644 --- a/internal/goscrobble/jellyfin.go +++ b/internal/goscrobble/jellyfin.go @@ -1,6 +1,50 @@ package goscrobble +import ( + "database/sql" + "errors" + "fmt" + "log" + "net" +) + // ParseJellyfinInput - Transform API data into a common struct -func ParseJellyfinInput(test string) string { - return test +func ParseJellyfinInput(userUUID string, data map[string]interface{}, ip net.IP, tx *sql.Tx) error { + log.Printf("%+v : %+v", userUUID, data) + + // Insert artist if not exist + artist, err := insertArtist(fmt.Sprintf("%s", data["Artist"]), fmt.Sprintf("%s", data["Provider_musicbrainzartist"]), tx) + if err != nil { + log.Printf("%+v", err) + return errors.New("Failed to map artist") + } + + // Insert album if not exist + artists := []string{artist.Uuid} + album, err := insertAlbum(fmt.Sprintf("%s", data["Album"]), fmt.Sprintf("%s", data["Provider_musicbrainzalbum"]), artists, tx) + if err != nil { + log.Printf("%+v", err) + return errors.New("Failed to map album") + } + + // Insert album if not exist + track, err := insertTrack(fmt.Sprintf("%s", data["Name"]), fmt.Sprintf("%s", data["Provider_musicbrainztrack"]), album.Uuid, artists, tx) + if err != nil { + log.Printf("%+v", err) + return errors.New("Failed to map track") + } + + // Insert album if not exist + err = insertScrobble(userUUID, track.Uuid, ip, tx) + if err != nil { + log.Printf("%+v", err) + return errors.New("Failed to map track") + } + + _ = album + _ = artist + _ = track + + // Insert track if not exist + return nil } diff --git a/internal/goscrobble/scrobble.go b/internal/goscrobble/scrobble.go new file mode 100644 index 00000000..48e63981 --- /dev/null +++ b/internal/goscrobble/scrobble.go @@ -0,0 +1,50 @@ +package goscrobble + +import ( + "database/sql" + "errors" + "log" + "net" + "time" +) + +type Scrobble struct { + Uuid string `json:"uuid"` + CreatedAt time.Time `json:"created_at"` + CreatedIp net.IP `json:"created_ip"` + User string `json:"user"` + Track string `json:"track"` +} + +// insertScrobble - This will return if it exists or create it based on MBID > Name +func insertScrobble(user string, track string, ip net.IP, tx *sql.Tx) error { + err := insertNewScrobble(user, track, ip, tx) + if err != nil { + log.Printf("Error inserting scrobble %s %+v", user, err) + return errors.New("Failed to insert scrobble!") + } + + return nil +} + +func fetchScrobble(col string, val string, tx *sql.Tx) Scrobble { + var scrobble Scrobble + err := tx.QueryRow( + "SELECT BIN_TO_UUID(`uuid`, true), `created_at`, `created_ip`, `user`, `track` FROM `scrobbles` WHERE `"+col+"` = ?", + val).Scan(&scrobble.Uuid, &scrobble.CreatedAt, &scrobble.CreatedIp, &scrobble.User, &scrobble.Track) + + if err != nil { + if err != sql.ErrNoRows { + log.Printf("Error fetching scrobbles: %+v", err) + } + } + + return scrobble +} + +func insertNewScrobble(user string, track string, ip net.IP, tx *sql.Tx) error { + _, err := tx.Exec("INSERT INTO `scrobbles` (`uuid`, `created_at`, `created_ip`, `user`, `track`) "+ + "VALUES (UUID_TO_BIN(UUID(), true), NOW(), ?, UUID_TO_BIN(?, true),UUID_TO_BIN(?, true))", ip, user, track) + + return err +} diff --git a/internal/goscrobble/server.go b/internal/goscrobble/server.go index 8a844e38..2791baad 100644 --- a/internal/goscrobble/server.go +++ b/internal/goscrobble/server.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "github.com/gorilla/mux" "github.com/rs/cors" @@ -25,10 +26,10 @@ type jsonResponse struct { } // Limits to 1 req / 10 sec -var heavyLimiter = NewIPRateLimiter(0.1, 1) +var heavyLimiter = NewIPRateLimiter(0.25, 2) // Limits to 5 req / sec -var standardLimiter = NewIPRateLimiter(5, 5) +var standardLimiter = NewIPRateLimiter(1, 5) // List of Reverse proxies var ReverseProxies []string @@ -45,15 +46,16 @@ func HandleRequests(port string) { v1 := r.PathPrefix("/api/v1").Subrouter() // Static Token for /ingress - v1.HandleFunc("/ingress/jellyfin", tokenMiddleware(serveEndpoint)) + v1.HandleFunc("/ingress/jellyfin", tokenMiddleware(handleIngress)) // JWT Auth - v1.HandleFunc("/profile/{id}", jwtMiddleware(serveEndpoint)) + // v1.HandleFunc("/profile/{id}", jwtMiddleware(handleIngress)) // No Auth v1.HandleFunc("/register", limitMiddleware(handleRegister, heavyLimiter)).Methods("POST") v1.HandleFunc("/login", limitMiddleware(handleLogin, standardLimiter)).Methods("POST") - v1.HandleFunc("/logout", serveEndpoint).Methods("POST") + // For now just trash JWT in frontend until we have full state management "Good enough" + // v1.HandleFunc("/logout", handleIngress).Methods("POST") // This just prevents it serving frontend stuff over /api r.PathPrefix("/api") @@ -126,9 +128,21 @@ func generateJsonError(m string) []byte { // tokenMiddleware - Validates token to a user func tokenMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - throwUnauthorized(w, "Invalid API Token") - return - // next(res, req) + fullToken := r.Header.Get("Authorization") + authToken := strings.Replace(fullToken, "Bearer ", "", 1) + if authToken == "" { + throwUnauthorized(w, "A token is required") + } + + userUuid, err := getUserForToken(authToken) + if err != nil { + throwUnauthorized(w, err.Error()) + return + } + + // Lets tack this on the request for now.. + r.Header.Set("UserUUID", userUuid) + next(w, r) } } @@ -136,8 +150,7 @@ func tokenMiddleware(next http.HandlerFunc) http.HandlerFunc { func jwtMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { throwUnauthorized(w, "Invalid JWT Token") - return - // next(res, req) + next(w, r) } } @@ -175,7 +188,7 @@ func handleRegister(w http.ResponseWriter, r *http.Request) { return } - msg := generateJsonMessage("User created succesfully") + msg := generateJsonMessage("User created succesfully. You may now login") w.WriteHeader(http.StatusCreated) w.Write(msg) } @@ -202,14 +215,35 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { } // serveEndpoint - API stuffs -func serveEndpoint(w http.ResponseWriter, r *http.Request) { - _, err := decodeJson(r.Body) +func handleIngress(w http.ResponseWriter, r *http.Request) { + bodyJson, err := decodeJson(r.Body) if err != nil { // If we can't decode. Lets tell them nicely. http.Error(w, "{\"error\":\"Invalid JSON\"}", http.StatusBadRequest) return } + ingressType := strings.Replace(r.URL.Path, "/api/v1/ingress/", "", 1) + log.Println(ingressType) + switch ingressType { + case "jellyfin": + tx, _ := db.Begin() + ip := getUserIp(r) + err := ParseJellyfinInput(r.Header.Get("UserUUID"), bodyJson, ip, tx) + if err != nil { + log.Printf("Error inserting track: %+v", err) + tx.Rollback() + throwBadReq(w, err.Error()) + return + } + err = tx.Commit() + if err != nil { + throwBadReq(w, err.Error()) + return + } + throwOkMessage(w, "{}") + return + } // Lets trick 'em for now ;) ;) fmt.Fprintf(w, "{}") } diff --git a/internal/goscrobble/tokens.go b/internal/goscrobble/tokens.go new file mode 100644 index 00000000..8505500d --- /dev/null +++ b/internal/goscrobble/tokens.go @@ -0,0 +1,25 @@ +package goscrobble + +import ( + "errors" + "math/rand" +) + +const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +func generateToken(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = letterBytes[rand.Int63()%int64(len(letterBytes))] + } + return string(b) +} + +func getUserForToken(token string) (string, error) { + var uuid string + err := db.QueryRow("SELECT BIN_TO_UUID(`uuid`, true) FROM `users` WHERE `token` = ? AND `active` = 1", token).Scan(&uuid) + if err != nil { + return "", errors.New("Invalid Token") + } + return uuid, nil +} diff --git a/internal/goscrobble/track.go b/internal/goscrobble/track.go new file mode 100644 index 00000000..a5aa3340 --- /dev/null +++ b/internal/goscrobble/track.go @@ -0,0 +1,112 @@ +package goscrobble + +import ( + "database/sql" + "errors" + "log" +) + +type Track struct { + Uuid string `json:"uuid"` + Name string `json:"name"` + Desc sql.NullString `json:"desc"` + Img sql.NullString `json:"img"` + MusicBrainzID sql.NullString `json:"mbid"` +} + +// insertTrack - This will return if it exists or create it based on MBID > Name +func insertTrack(name string, mbid string, album string, artists []string, tx *sql.Tx) (Track, error) { + track := Track{} + + if mbid != "" { + track = fetchTrack("mbid", mbid, tx) + if track.Uuid == "" { + err := insertNewTrack(name, mbid, tx) + if err != nil { + log.Printf("Error inserting track via MBID %s %+v", name, err) + return track, errors.New("Failed to insert track") + } + + track = fetchTrack("mbid", mbid, tx) + err = track.linkTrack(album, artists, tx) + if err != nil { + return track, err + } + } + } else { + track = fetchTrack("name", name, tx) + if track.Uuid == "" { + err := insertNewTrack(name, mbid, tx) + if err != nil { + log.Printf("Error inserting track via Name %s %+v", name, err) + return track, errors.New("Failed to insert track") + } + + track = fetchTrack("name", name, tx) + err = track.linkTrack(album, artists, tx) + if err != nil { + return track, err + } + } + } + + if track.Uuid == "" { + return track, errors.New("Unable to fetch track!") + } + + return track, nil +} + +func fetchTrack(col string, val string, tx *sql.Tx) Track { + var track Track + err := tx.QueryRow( + "SELECT BIN_TO_UUID(`uuid`, true), `name`, `desc`, `img`, `mbid` FROM `tracks` WHERE `"+col+"` = ?", + val).Scan(&track.Uuid, &track.Name, &track.Desc, &track.Img, &track.MusicBrainzID) + + if err != nil { + if err != sql.ErrNoRows { + log.Printf("Error fetching tracks: %+v", err) + } + } + + return track +} + +func insertNewTrack(name string, mbid string, tx *sql.Tx) error { + _, err := tx.Exec("INSERT INTO `tracks` (`uuid`, `name`, `mbid`) "+ + "VALUES (UUID_TO_BIN(UUID(), true),?,?)", name, mbid) + + return err +} + +func (track *Track) linkTrack(album string, artists []string, tx *sql.Tx) error { + err := track.linkTrackToAlbum(album, tx) + if err != nil { + return err + } + err = track.linkTrackToArtists(artists, tx) + if err != nil { + return err + } + return nil +} + +func (track Track) linkTrackToAlbum(albumUuid string, tx *sql.Tx) error { + _, err := tx.Exec("INSERT INTO `track_album` (`track`, `album`) "+ + "VALUES (UUID_TO_BIN(?, true), UUID_TO_BIN(?, true))", track.Uuid, albumUuid) + + return err +} + +func (track Track) linkTrackToArtists(artists []string, tx *sql.Tx) error { + var err error + for _, artist := range artists { + _, err = tx.Exec("INSERT INTO `track_artist` (`track`, `artist`) "+ + "VALUES (UUID_TO_BIN(?, true),UUID_TO_BIN(?, true))", track.Uuid, artist) + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/goscrobble/user.go b/internal/goscrobble/user.go index a604f926..dd5f0340 100644 --- a/internal/goscrobble/user.go +++ b/internal/goscrobble/user.go @@ -153,13 +153,14 @@ func generateJwt(user User) (string, error) { // insertUser - Does the dirtywork! func insertUser(username string, email string, password []byte, ip net.IP) error { - _, err := db.Exec("INSERT INTO users (uuid, created_at, created_ip, modified_at, modified_ip, username, email, password) "+ - "VALUES (UUID_TO_BIN(UUID(), true),NOW(),?,NOW(),?,?,?,?)", ip, ip, username, email, password) + token := generateToken(32) + _, err := db.Exec("INSERT INTO users (uuid, created_at, created_ip, modified_at, modified_ip, username, email, password, token) "+ + "VALUES (UUID_TO_BIN(UUID(), true),NOW(),?,NOW(),?,?,?,?,?)", ip, ip, username, email, password, token) return err } -func updateUser(uuid string, field string, value string, ip string) error { +func updateUser(uuid string, field string, value string, ip net.IP) error { _, err := db.Exec("UPDATE users SET ? = ?, modified_at = NOW(), modified_ip = ? WHERE uuid = ?", field, value, uuid, ip) return err diff --git a/internal/goscrobble/utils.go b/internal/goscrobble/utils.go index a5dc3d39..0078368f 100644 --- a/internal/goscrobble/utils.go +++ b/internal/goscrobble/utils.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "encoding/json" "io" + "log" "math/big" "net" "net/http" @@ -44,11 +45,14 @@ func getUserIp(r *http.Request) net.IP { var ip net.IP host, _, _ := net.SplitHostPort(r.RemoteAddr) if contains(ReverseProxies, host) { - host = r.Header.Get("X-FOWARDED-FOR") + forwardedFor := r.Header.Get("X-FOWARDED-FOR") + if forwardedFor != "" { + host = forwardedFor + } } ip = net.ParseIP(host) - + log.Printf("%+v", ip) return ip } diff --git a/migrations/3_tracks.up.sql b/migrations/3_tracks.up.sql index 98a954cf..8219705e 100644 --- a/migrations/3_tracks.up.sql +++ b/migrations/3_tracks.up.sql @@ -9,23 +9,23 @@ CREATE TABLE IF NOT EXISTS `links` ( CREATE TABLE IF NOT EXISTS `artists` ( `uuid` BINARY(16) PRIMARY KEY, - `name` BINARY(16) NOT NULL, + `name` VARCHAR(255) NOT NULL, `desc` TEXT, - `img` VARCHAR(255) DEFAULT '' + `img` VARCHAR(255) DEFAULT NULL ) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci; CREATE TABLE IF NOT EXISTS `albums` ( `uuid` BINARY(16) PRIMARY KEY, - `name` BINARY(16) NOT NULL, + `name` VARCHAR(255) NOT NULL, `desc` TEXT, - `img` VARCHAR(255) DEFAULT '' + `img` VARCHAR(255) DEFAULT NULL ) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci; CREATE TABLE IF NOT EXISTS `tracks` ( `uuid` BINARY(16) PRIMARY KEY, - `name` BINARY(16) NOT NULL, + `name` VARCHAR(255) NOT NULL, `desc` TEXT, - `img` VARCHAR(255) DEFAULT '' + `img` VARCHAR(255) DEFAULT NULL ) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci; CREATE TABLE IF NOT EXISTS `scrobbles` ( @@ -40,14 +40,6 @@ CREATE TABLE IF NOT EXISTS `scrobbles` ( FOREIGN KEY (user) REFERENCES users(uuid) ) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci; -CREATE TABLE IF NOT EXISTS `track_artist` ( - `track` BINARY(16) NOT NULL, - `artist` BINARY(16) NOT NULL, - PRIMARY KEY (`track`, `artist`), - FOREIGN KEY (track) REFERENCES tracks(uuid), - FOREIGN KEY (artist) REFERENCES artists(uuid) -) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci; - CREATE TABLE IF NOT EXISTS `album_artist` ( `album` BINARY(16) NOT NULL, `artist` BINARY(16) NOT NULL, @@ -56,4 +48,20 @@ CREATE TABLE IF NOT EXISTS `album_artist` ( FOREIGN KEY (artist) REFERENCES artists(uuid) ) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci; +CREATE TABLE IF NOT EXISTS `track_album` ( + `track` BINARY(16) NOT NULL, + `album` BINARY(16) NOT NULL, + PRIMARY KEY (`track`, `album`), + FOREIGN KEY (track) REFERENCES tracks(uuid), + FOREIGN KEY (album) REFERENCES albums(uuid) +) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `track_artist` ( + `track` BINARY(16) NOT NULL, + `artist` BINARY(16) NOT NULL, + PRIMARY KEY (`track`, `artist`), + FOREIGN KEY (track) REFERENCES tracks(uuid), + FOREIGN KEY (artist) REFERENCES artists(uuid) +) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci; + COMMIT; diff --git a/migrations/4_mbid.down.sql b/migrations/4_mbid.down.sql new file mode 100644 index 00000000..0322c21e --- /dev/null +++ b/migrations/4_mbid.down.sql @@ -0,0 +1,7 @@ +START TRANSACTION; + +ALTER TABLE albums DROP COLUMN `mbid`; +ALTER TABLE artists DROP COLUMN `mbid`; +ALTER TABLE tracks DROP COLUMN `mbid`; + +COMMIT; diff --git a/migrations/5_apitoken.down.sql b/migrations/5_apitoken.down.sql new file mode 100644 index 00000000..3b9ffb0d --- /dev/null +++ b/migrations/5_apitoken.down.sql @@ -0,0 +1 @@ +ALTER TABLE `users` DROP COLUMN `token`; \ No newline at end of file diff --git a/migrations/5_apitoken.up.sql b/migrations/5_apitoken.up.sql new file mode 100644 index 00000000..e11faf55 --- /dev/null +++ b/migrations/5_apitoken.up.sql @@ -0,0 +1 @@ +ALTER TABLE `users` ADD COLUMN `token` VARCHAR(32) NOT NULL; \ No newline at end of file diff --git a/test/jellyfin_test.go b/test/jellyfin_test.go deleted file mode 100644 index d9933987..00000000 --- a/test/jellyfin_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package goscrobble - -import ( - "testing" - - "git.m2.nz/go-scrobble/internal/goscrobble" -) - -func TestParseJellyfinInput(t *testing.T) { - got := goscrobble.ParseJellyfinInput("TestString!") - if got != "TestString!" { - t.Errorf("ParseJellyfinInput returned: %s", got) - } -} diff --git a/web/package-lock.json b/web/package-lock.json index 664fceb3..b6987644 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -21,6 +21,7 @@ "react-redux": "^7.2.3", "react-router-dom": "^5.2.0", "react-scripts": "4.0.3", + "react-spinners": "^0.10.6", "react-toast": "^1.0.1", "react-toast-notifications": "^2.4.3", "reactstrap": "^8.9.0", @@ -16679,6 +16680,18 @@ "semver": "bin/semver" } }, + "node_modules/react-spinners": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/react-spinners/-/react-spinners-0.10.6.tgz", + "integrity": "sha512-UPLcaMFhFnLWtS1zVDDT14ssW08gnZ44cjPN6GL27dEGboiCvRSthOilZXRDWESp449GB2iI6gjEbxzaMtA+dg==", + "dependencies": { + "@emotion/core": "^10.0.35" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0", + "react-dom": "^16.0.0 || ^17.0.0" + } + }, "node_modules/react-toast": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/react-toast/-/react-toast-1.0.1.tgz", @@ -35210,6 +35223,14 @@ } } }, + "react-spinners": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/react-spinners/-/react-spinners-0.10.6.tgz", + "integrity": "sha512-UPLcaMFhFnLWtS1zVDDT14ssW08gnZ44cjPN6GL27dEGboiCvRSthOilZXRDWESp449GB2iI6gjEbxzaMtA+dg==", + "requires": { + "@emotion/core": "^10.0.35" + } + }, "react-toast": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/react-toast/-/react-toast-1.0.1.tgz", diff --git a/web/package.json b/web/package.json index e6c794f8..920e3084 100644 --- a/web/package.json +++ b/web/package.json @@ -16,6 +16,7 @@ "react-redux": "^7.2.3", "react-router-dom": "^5.2.0", "react-scripts": "4.0.3", + "react-spinners": "^0.10.6", "react-toast": "^1.0.1", "react-toast-notifications": "^2.4.3", "reactstrap": "^8.9.0", diff --git a/web/src/App.js b/web/src/App.js index 30b0c7a2..661ad89a 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -1,12 +1,13 @@ import './App.css'; import Home from './Components/Pages/Home'; import About from './Components/Pages/About'; +import Help from './Components/Pages/Help'; import Login from './Components/Pages/Login'; import Settings from './Components/Pages/Settings'; import Register from './Components/Pages/Register'; import Navigation from './Components/Navigation'; -import { Route, Switch } from 'react-router-dom'; +import { Route, Switch, withRouter } from 'react-router-dom'; import { connect } from "react-redux"; import '../node_modules/bootstrap/dist/css/bootstrap.min.css'; @@ -32,6 +33,7 @@ const App = () => { + @@ -39,4 +41,4 @@ const App = () => { ); } -export default connect(mapStateToProps, mapDispatchToProps)(App); +export default withRouter(connect(mapStateToProps, mapDispatchToProps)(App)); diff --git a/web/src/Components/Navigation.js b/web/src/Components/Navigation.js index 7325143c..e5c2ffe0 100644 --- a/web/src/Components/Navigation.js +++ b/web/src/Components/Navigation.js @@ -5,6 +5,7 @@ import './Navigation.css'; const menuItems = [ 'Home', + 'Help', 'About', ]; @@ -37,7 +38,7 @@ class Navigation extends Component { } else { return
Login - Register + Register
; } } diff --git a/web/src/Components/Pages/Help.css b/web/src/Components/Pages/Help.css new file mode 100644 index 00000000..8a669070 --- /dev/null +++ b/web/src/Components/Pages/Help.css @@ -0,0 +1,4 @@ +.helpBody { + padding: 20px 5px 5px 5px; + font-size: 16pt; +} \ No newline at end of file diff --git a/web/src/Components/Pages/Help.js b/web/src/Components/Pages/Help.js new file mode 100644 index 00000000..425cecca --- /dev/null +++ b/web/src/Components/Pages/Help.js @@ -0,0 +1,17 @@ +import '../../App.css'; +import './Help.css'; + +function Help() { + return ( +
+

+ Help Docs +

+

+ Jellyfin Configuration
+

+
+ ); +} + +export default Help; diff --git a/web/src/Components/Pages/Login.js b/web/src/Components/Pages/Login.js index 7a3521d7..b455a618 100644 --- a/web/src/Components/Pages/Login.js +++ b/web/src/Components/Pages/Login.js @@ -4,6 +4,7 @@ import './Login.css'; import { Button } from 'reactstrap'; import { Formik, Form, Field } from 'formik'; import { useToasts } from 'react-toast-notifications'; +import ScaleLoader from "react-spinners/ScaleLoader"; function withToast(Component) { return function WrappedComponent(props) { @@ -42,7 +43,14 @@ class Login extends React.Component { }; const apiUrl = process.env.REACT_APP_API_URL + '/api/v1/login'; fetch(apiUrl, requestOptions) - .then((response) => response.json()) + .then((response) => { + if (response.status === 429) { + this.props.addToast("Rate limited. Please try again soon", { appearance: 'error' }); + return "{}" + } else { + return response.json() + } + }) .then((function(data) { if (data.error) { this.props.addToast(data.error, { appearance: 'error' }); @@ -95,7 +103,7 @@ class Login extends React.Component { type="submit" className="loginButton" disabled={this.state.loading} - >Login + >{this.state.loading ? : "Login"} diff --git a/web/src/Components/Pages/Register.js b/web/src/Components/Pages/Register.js index de8abeed..8d32c3b3 100644 --- a/web/src/Components/Pages/Register.js +++ b/web/src/Components/Pages/Register.js @@ -3,6 +3,8 @@ import '../../App.css'; import './Login.css'; import { Button } from 'reactstrap'; import { useToasts } from 'react-toast-notifications'; +import ScaleLoader from "react-spinners/ScaleLoader"; +import { withRouter } from 'react-router-dom' function withToast(Component) { return function WrappedComponent(props) { @@ -66,13 +68,21 @@ class Register extends React.Component { const apiUrl = process.env.REACT_APP_API_URL + '/api/v1/register'; console.log(apiUrl); fetch(apiUrl, requestOptions) - .then((response) => response.json()) + .then((response) => { + if (response.status === 429) { + this.props.addToast("Rate limited. Please try again soon", { appearance: 'error' }); + return "{}" + } else { + return response.json() + } + }) .then((function(data) { console.log(data); if (data.error) { this.props.addToast(data.error, { appearance: 'error' }); - } else { + } else if (data.message) { this.props.addToast(data.message, { appearance: 'success' }); + this.props.history.push('/login') } this.setState({loading: false}); }).bind(this)) @@ -119,7 +129,7 @@ class Register extends React.Component {