diff --git a/.env.example b/.env.example index aa3cbdb0..90e2c543 100644 --- a/.env.example +++ b/.env.example @@ -20,5 +20,6 @@ SENDGRID_API_KEY= MAIL_FROM_ADDRESS= MAIL_FROM_NAME= +DEV_MODE=false GOSCROBBLE_DOMAIN="" STATIC_DIR="web" diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f1ece838..9760bb90 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,10 +3,10 @@ stages: - bundle variables: - VERSION: 0.0.31 + VERSION: 0.0.32 build-go: - image: golang:1.16.2 + image: golang:1.16.7 stage: build only: - master diff --git a/cmd/go-scrobble/main.go b/cmd/go-scrobble/main.go index d7dfcf0b..7d132ce8 100644 --- a/cmd/go-scrobble/main.go +++ b/cmd/go-scrobble/main.go @@ -60,6 +60,12 @@ func main() { // Ignore reverse proxies goscrobble.ReverseProxies = strings.Split(os.Getenv("REVERSE_PROXIES"), ",") + goscrobble.DevMode = false + devModeString := os.Getenv("DEV_MODE") + if strings.ToLower(devModeString) == "true" { + goscrobble.DevMode = true + } + // Store port port := os.Getenv("PORT") if port == "" { @@ -74,9 +80,14 @@ func main() { goscrobble.InitRedis() defer goscrobble.CloseRedisConn() - // Start background workers - go goscrobble.StartBackgroundWorkers() - defer goscrobble.EndBackgroundWorkers() + // Start background workers if not DevMode + if !goscrobble.DevMode { + go goscrobble.StartBackgroundWorkers() + defer goscrobble.EndBackgroundWorkers() + } else { + fmt.Printf("Running in DevMode. No background workers running") + fmt.Println("") + } // Boot up API webserver \o/ goscrobble.HandleRequests(port) diff --git a/docs/changelog.md b/docs/changelog.md index eb1a0ccb..36e65e13 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,3 +1,10 @@ +# 0.0.32 +- Add related records into track API +- Build out track page to show links to related records +- Tidy UI *even more* +- Bump golang build to 1.16.7 +- Added DevMode env var. This prevents the background workers running on local machines + # 0.0.31 - Added newlines for flamerohr - Tidied pages diff --git a/docs/config.md b/docs/config.md index ac9b09cc..8b066223 100644 --- a/docs/config.md +++ b/docs/config.md @@ -30,5 +30,6 @@ These are stored in `web/.env.production` and `web/.env.development` MAIL_FROM_ADDRESS= // FROM email MAIL_FROM_NAME= // FROM name + DEV_MODE=false // true|false - Defaults false GOSCROBBLE_DOMAIN="" // Full domain for email links (https://goscrobble.com)) STATIC_DIR="web" // Location to store images (This will serve from web/static) diff --git a/internal/goscrobble/server.go b/internal/goscrobble/server.go index 80cb6cd1..9a889710 100644 --- a/internal/goscrobble/server.go +++ b/internal/goscrobble/server.go @@ -71,12 +71,16 @@ func HandleRequests(port string) { // No Auth v1.HandleFunc("/stats", limitMiddleware(handleStats, lightLimiter)).Methods("GET") v1.HandleFunc("/profile/{username}", limitMiddleware(getProfile, lightLimiter)).Methods("GET") + v1.HandleFunc("/artists/top/{uuid}", limitMiddleware(getArtists, lightLimiter)).Methods("GET") v1.HandleFunc("/artists/{uuid}", limitMiddleware(getArtist, lightLimiter)).Methods("GET") + v1.HandleFunc("/albums/top/{uuid}", limitMiddleware(getArtists, lightLimiter)).Methods("GET") v1.HandleFunc("/albums/{uuid}", limitMiddleware(getAlbum, lightLimiter)).Methods("GET") - v1.HandleFunc("/tracks/top/{uuid}", limitMiddleware(getTracks, lightLimiter)).Methods("GET") - v1.HandleFunc("/tracks/{uuid}", limitMiddleware(getTrack, lightLimiter)).Methods("GET") + + v1.HandleFunc("/tracks/top/{uuid}", limitMiddleware(getTracks, lightLimiter)).Methods("GET") // User UUID - Top Tracks + v1.HandleFunc("/tracks/{uuid}", limitMiddleware(getTrack, lightLimiter)).Methods("GET") // Track UUID + v1.HandleFunc("/tracks/{uuid}/top", limitMiddleware(getTopUsersForTrack, lightLimiter)).Methods("GET") // TrackUUID - Top Listeners v1.HandleFunc("/register", limitMiddleware(handleRegister, heavyLimiter)).Methods("POST") v1.HandleFunc("/login", limitMiddleware(handleLogin, standardLimiter)).Methods("POST") @@ -648,6 +652,31 @@ func getTracks(w http.ResponseWriter, r *http.Request) { w.Write(json) } +// getTopUsersForTrack - I suck at naming. Returns top users that have scrobbled this track. +func getTopUsersForTrack(w http.ResponseWriter, r *http.Request) { + var uuid string + for k, v := range mux.Vars(r) { + if k == "uuid" { + uuid = v + } + } + + if uuid == "" { + throwOkError(w, "Invalid UUID") + return + } + + userList, err := getTopUsersForTrackUUID(uuid, 10, 1) + if err != nil { + throwOkError(w, err.Error()) + return + } + + json, _ := json.Marshal(&userList) + w.WriteHeader(http.StatusOK) + w.Write(json) +} + // postSpotifyResponse - Oauth Response from Spotify func postSpotifyReponse(w http.ResponseWriter, r *http.Request) { err := connectSpotifyResponse(r) @@ -748,7 +777,7 @@ func getServerInfo(w http.ResponseWriter, r *http.Request) { } info := ServerInfo{ - Version: "0.0.31", + Version: "0.0.32", RegistrationEnabled: cachedRegistrationEnabled, } diff --git a/internal/goscrobble/track.go b/internal/goscrobble/track.go index c40ecfeb..14ad1123 100644 --- a/internal/goscrobble/track.go +++ b/internal/goscrobble/track.go @@ -30,6 +30,23 @@ type TopTracks struct { Tracks map[int]TopTrack `json:"tracks"` } +type TopUserTrackResponse struct { + Meta TopUserTrackResponseMeta `json:"meta"` + Items []TopUserTrackResponseItem `json:"items"` +} + +type TopUserTrackResponseMeta struct { + Count int `json:"count"` + Total int `json:"total"` + Page int `json:"page"` +} + +type TopUserTrackResponseItem struct { + UserUUID string `json:"user_uuid"` + Count int `json:"count"` + UserName string `json:"user_name"` +} + // insertTrack - This will return if it exists or create it based on MBID > Name func insertTrack(name string, legnth int, mbid string, spotifyId string, album string, artists []string, tx *sql.Tx) (Track, error) { track := Track{} @@ -294,3 +311,57 @@ func (track *Track) getAlbumsForTrack() error { track.Albums = albums return nil } + +// getTopUsersForTrackUUID - Returns list of top users for a track +func getTopUsersForTrackUUID(trackUUID string, limit int, page int) (TopUserTrackResponse, error) { + response := TopUserTrackResponse{} + var count int + + // Yeah this isn't great. But for now.. it works! Cache later + // TODO: This is counting total scrobbles, not unique users + total, err := getDbCount( + "SELECT COUNT(*) FROM `scrobbles` WHERE `track` = UUID_TO_BIN(?, true) GROUP BY `user`", trackUUID) + + if err != nil { + log.Printf("Failed to fetch scrobble count: %+v", err) + return response, errors.New("Failed to fetch combined scrobbles") + } + + rows, err := db.Query( + "SELECT BIN_TO_UUID(`scrobbles`.`user`, true), `users`.`username`, COUNT(*) "+ + "FROM `scrobbles` "+ + "JOIN `users` ON `scrobbles`.`user` = `users`.`uuid` "+ + "WHERE `track` = UUID_TO_BIN(?, true) "+ + "GROUP BY `scrobbles`.`user` "+ + "ORDER BY COUNT(*) DESC LIMIT ?", + trackUUID, limit) + + if err != nil { + log.Printf("Failed to fetch scrobbles: %+v", err) + return response, errors.New("Failed to fetch combined scrobbles") + } + defer rows.Close() + + for rows.Next() { + item := TopUserTrackResponseItem{} + err := rows.Scan(&item.UserUUID, &item.UserName, &item.Count) + if err != nil { + log.Printf("Failed to fetch scrobbles: %+v", err) + return response, errors.New("Failed to fetch combined scrobbles") + } + count++ + response.Items = append(response.Items, item) + } + + err = rows.Err() + if err != nil { + log.Printf("Failed to fetch scrobbles: %+v", err) + return response, errors.New("Failed to fetch scrobbles") + } + + response.Meta.Count = count + response.Meta.Total = total + response.Meta.Page = page + + return response, nil +} diff --git a/internal/goscrobble/utils.go b/internal/goscrobble/utils.go index c292b96a..6745f0d8 100644 --- a/internal/goscrobble/utils.go +++ b/internal/goscrobble/utils.go @@ -16,6 +16,9 @@ import ( "github.com/google/uuid" ) +// DevMode - Controls background workers and probably more +var DevMode bool + var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") var usernameRegex = regexp.MustCompile("^[a-zA-Z0-9_\\.]+$") diff --git a/web/src/Api/index.js b/web/src/Api/index.js index 3114a660..5f2cbc7f 100644 --- a/web/src/Api/index.js +++ b/web/src/Api/index.js @@ -354,4 +354,13 @@ export const getTopArtists = (uuid) => { }).catch((error) => { return handleErrorResp(error) }); +} + +export const getTopUsersForTrack = (uuid) => { + return axios.get(process.env.REACT_APP_API_URL + "/api/v1/tracks/" + uuid + "/top").then( + (data) => { + return data.data; + }).catch((error) => { + return handleErrorResp(error) + }); } \ No newline at end of file diff --git a/web/src/App.css b/web/src/App.css index 20882da3..d714fdf3 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -1,5 +1,7 @@ html, body { background-color: #282c34; + /** WHY DOES THIS DEFAULT TO 1.5 */ + line-height: 1.3!important; } .App { diff --git a/web/src/Components/Navigation.js b/web/src/Components/Navigation.js index 405750b4..285d594a 100644 --- a/web/src/Components/Navigation.js +++ b/web/src/Components/Navigation.js @@ -8,7 +8,7 @@ import AuthContext from '../Contexts/AuthContext'; const menuItems = [ 'Home', - 'About', + // 'About', ]; const loggedInMenuItems = [ diff --git a/web/src/Components/TopUserTable.css b/web/src/Components/TopUserTable.css new file mode 100644 index 00000000..e69de29b diff --git a/web/src/Components/TopUserTable.js b/web/src/Components/TopUserTable.js new file mode 100644 index 00000000..0d6c6bb0 --- /dev/null +++ b/web/src/Components/TopUserTable.js @@ -0,0 +1,56 @@ +import { Link } from 'react-router-dom'; +import './TopUserTable.css' +import React, { useState, useEffect } from 'react'; +import ScaleLoader from 'react-spinners/ScaleLoader'; +import { getTopUsersForTrack } from '../Api/index' + +const TopUserTable = (props) => { + const [loading, setLoading] = useState(true); + const [data, setData] = useState({}); + + + useEffect(() => { + if (!props.uuid) { + return false; + } + + getTopUsersForTrack(props.uuid) + .then(data => { + setData(data); + setLoading(false); + }) + }, [props.uuid]) + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+ { + data.items && + data.items.map(function (element) { + return
+ {element.user_name} ({element.count}) +
; + + }) + } +
+ ); +} + +export default TopUserTable; \ No newline at end of file diff --git a/web/src/Pages/Docs.css b/web/src/Pages/Docs.css deleted file mode 100644 index 8b137891..00000000 --- a/web/src/Pages/Docs.css +++ /dev/null @@ -1 +0,0 @@ - diff --git a/web/src/Pages/Docs.js b/web/src/Pages/Docs.js deleted file mode 100644 index b55b30fb..00000000 --- a/web/src/Pages/Docs.js +++ /dev/null @@ -1,25 +0,0 @@ -import '../App.css'; -import './Docs.css'; - -const Docs = () => { - return ( -
-

- Documentation -

-

- Go-Scrobble is an open source music scorbbling service written in Go and React.
- Used to track your listening history and build a profile to discover new music. -

- gitlab.com/idanoo/go-scrobble - -
- ); -} - -export default Docs; diff --git a/web/src/Pages/Track.js b/web/src/Pages/Track.js index 9a51dd66..7702954a 100644 --- a/web/src/Pages/Track.js +++ b/web/src/Pages/Track.js @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import '../App.css'; import './Track.css'; +import TopUserTable from '../Components/TopUserTable'; import ScaleLoader from 'react-spinners/ScaleLoader'; import { getTrack } from '../Api/index' import { Link } from 'react-router-dom'; @@ -80,22 +81,24 @@ const Track = (route) => {
- - - {artists} - -
- - {albums} - - {track.name}

+ {track.name} +
+
+ + {artists} + +
+ + {albums} + +

{track.mbid && Open on MusicBrainz
} {track.spotify_id && Open on Spotify
} - Track Length: {length && length} + {length && Track Length: {length}}
-
-

Top Users

-
+
+

Top 10 Scrobblers

+
diff --git a/web/src/Pages/User.css b/web/src/Pages/User.css index 26b27658..53b6c0c5 100644 --- a/web/src/Pages/User.css +++ b/web/src/Pages/User.css @@ -12,6 +12,7 @@ .modal { font-size: 12px; } + .modal > .header { width: 100%; border-bottom: 1px solid gray;