0.0.32 Flesh out track page

This commit is contained in:
Daniel Mason 2021-08-13 21:37:53 +12:00
parent d7b7ceb122
commit 9074149925
Signed by: idanoo
GPG Key ID: 387387CDBC02F132
17 changed files with 216 additions and 48 deletions

View File

@ -20,5 +20,6 @@ SENDGRID_API_KEY=
MAIL_FROM_ADDRESS= MAIL_FROM_ADDRESS=
MAIL_FROM_NAME= MAIL_FROM_NAME=
DEV_MODE=false
GOSCROBBLE_DOMAIN="" GOSCROBBLE_DOMAIN=""
STATIC_DIR="web" STATIC_DIR="web"

View File

@ -3,10 +3,10 @@ stages:
- bundle - bundle
variables: variables:
VERSION: 0.0.31 VERSION: 0.0.32
build-go: build-go:
image: golang:1.16.2 image: golang:1.16.7
stage: build stage: build
only: only:
- master - master

View File

@ -60,6 +60,12 @@ func main() {
// Ignore reverse proxies // Ignore reverse proxies
goscrobble.ReverseProxies = strings.Split(os.Getenv("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 // Store port
port := os.Getenv("PORT") port := os.Getenv("PORT")
if port == "" { if port == "" {
@ -74,9 +80,14 @@ func main() {
goscrobble.InitRedis() goscrobble.InitRedis()
defer goscrobble.CloseRedisConn() defer goscrobble.CloseRedisConn()
// Start background workers // Start background workers if not DevMode
go goscrobble.StartBackgroundWorkers() if !goscrobble.DevMode {
defer goscrobble.EndBackgroundWorkers() go goscrobble.StartBackgroundWorkers()
defer goscrobble.EndBackgroundWorkers()
} else {
fmt.Printf("Running in DevMode. No background workers running")
fmt.Println("")
}
// Boot up API webserver \o/ // Boot up API webserver \o/
goscrobble.HandleRequests(port) goscrobble.HandleRequests(port)

View File

@ -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 # 0.0.31
- Added newlines for flamerohr - Added newlines for flamerohr
- Tidied pages - Tidied pages

View File

@ -30,5 +30,6 @@ These are stored in `web/.env.production` and `web/.env.development`
MAIL_FROM_ADDRESS= // FROM email MAIL_FROM_ADDRESS= // FROM email
MAIL_FROM_NAME= // FROM name MAIL_FROM_NAME= // FROM name
DEV_MODE=false // true|false - Defaults false
GOSCROBBLE_DOMAIN="" // Full domain for email links (https://goscrobble.com)) GOSCROBBLE_DOMAIN="" // Full domain for email links (https://goscrobble.com))
STATIC_DIR="web" // Location to store images (This will serve from web/static) STATIC_DIR="web" // Location to store images (This will serve from web/static)

View File

@ -71,12 +71,16 @@ func HandleRequests(port string) {
// No Auth // No Auth
v1.HandleFunc("/stats", limitMiddleware(handleStats, lightLimiter)).Methods("GET") v1.HandleFunc("/stats", limitMiddleware(handleStats, lightLimiter)).Methods("GET")
v1.HandleFunc("/profile/{username}", limitMiddleware(getProfile, 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/top/{uuid}", limitMiddleware(getArtists, lightLimiter)).Methods("GET")
v1.HandleFunc("/artists/{uuid}", limitMiddleware(getArtist, 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/top/{uuid}", limitMiddleware(getArtists, lightLimiter)).Methods("GET")
v1.HandleFunc("/albums/{uuid}", limitMiddleware(getAlbum, 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("/register", limitMiddleware(handleRegister, heavyLimiter)).Methods("POST")
v1.HandleFunc("/login", limitMiddleware(handleLogin, standardLimiter)).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) 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 // postSpotifyResponse - Oauth Response from Spotify
func postSpotifyReponse(w http.ResponseWriter, r *http.Request) { func postSpotifyReponse(w http.ResponseWriter, r *http.Request) {
err := connectSpotifyResponse(r) err := connectSpotifyResponse(r)
@ -748,7 +777,7 @@ func getServerInfo(w http.ResponseWriter, r *http.Request) {
} }
info := ServerInfo{ info := ServerInfo{
Version: "0.0.31", Version: "0.0.32",
RegistrationEnabled: cachedRegistrationEnabled, RegistrationEnabled: cachedRegistrationEnabled,
} }

View File

@ -30,6 +30,23 @@ type TopTracks struct {
Tracks map[int]TopTrack `json:"tracks"` 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 // 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) { func insertTrack(name string, legnth int, mbid string, spotifyId string, album string, artists []string, tx *sql.Tx) (Track, error) {
track := Track{} track := Track{}
@ -294,3 +311,57 @@ func (track *Track) getAlbumsForTrack() error {
track.Albums = albums track.Albums = albums
return nil 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
}

View File

@ -16,6 +16,9 @@ import (
"github.com/google/uuid" "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 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_\\.]+$") var usernameRegex = regexp.MustCompile("^[a-zA-Z0-9_\\.]+$")

View File

@ -354,4 +354,13 @@ export const getTopArtists = (uuid) => {
}).catch((error) => { }).catch((error) => {
return handleErrorResp(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)
});
} }

View File

@ -1,5 +1,7 @@
html, body { html, body {
background-color: #282c34; background-color: #282c34;
/** WHY DOES THIS DEFAULT TO 1.5 */
line-height: 1.3!important;
} }
.App { .App {

View File

@ -8,7 +8,7 @@ import AuthContext from '../Contexts/AuthContext';
const menuItems = [ const menuItems = [
'Home', 'Home',
'About', // 'About',
]; ];
const loggedInMenuItems = [ const loggedInMenuItems = [

View File

View File

@ -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 (
<div className="pageWrapper">
<ScaleLoader color="#6AD7E5" />
</div>
)
}
return (
<div style={{
width: `100%`,
display: `flex`,
flexWrap: `wrap`,
marginLeft: `20px`,
textAlign: `left`,
}}>
{
data.items &&
data.items.map(function (element) {
return <div style={{width: `100%`, padding: `2px`}} key={"box" + props.uuid}>
<Link
key={"user" + element.user_uuid}
to={"/u/"+element.user_name}
>{element.user_name}</Link> ({element.count})
</div>;
})
}
</div>
);
}
export default TopUserTable;

View File

@ -1 +0,0 @@

View File

@ -1,25 +0,0 @@
import '../App.css';
import './Docs.css';
const Docs = () => {
return (
<div className="pageWrapper">
<h1>
Documentation
</h1>
<p className="aboutBody">
Go-Scrobble is an open source music scorbbling service written in Go and React.<br/>
Used to track your listening history and build a profile to discover new music.
</p>
<a
className="pageBody"
href="https://gitlab.com/idanoo/go-scrobble"
target="_blank"
rel="noopener noreferrer"
>gitlab.com/idanoo/go-scrobble
</a>
</div>
);
}
export default Docs;

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import '../App.css'; import '../App.css';
import './Track.css'; import './Track.css';
import TopUserTable from '../Components/TopUserTable';
import ScaleLoader from 'react-spinners/ScaleLoader'; import ScaleLoader from 'react-spinners/ScaleLoader';
import { getTrack } from '../Api/index' import { getTrack } from '../Api/index'
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@ -80,22 +81,24 @@ const Track = (route) => {
<div className="pageBody"> <div className="pageBody">
<div style={{display: `flex`, flexWrap: `wrap`, textAlign: `center`}}> <div style={{display: `flex`, flexWrap: `wrap`, textAlign: `center`}}>
<div style={{width: `300px`, padding: `0 10px 10px 10px`, textAlign: `left`}}> <div style={{width: `300px`, padding: `0 10px 10px 10px`, textAlign: `left`}}>
<img src={process.env.REACT_APP_API_URL + "/img/" + track.img + "_full.jpg"} alt={track.name} style={{maxWidth: `300px`, maxHeight: `300px`}}/>
<span style={{fontSize: '14pt'}}> </div>
{artists} <div style={{width: `290px`, padding: `0 10px 10px 10px`, margin: `0 5px 0 5px`, textAlign: `left`}}>
</span> <span style={{fontSize: '14pt'}}>
<br/> {artists}
<span style={{fontSize: '12pt'}}> </span>
{albums} <br/>
</span> <span style={{fontSize: '14pt', textDecoration: 'none'}}>
<img src={process.env.REACT_APP_API_URL + "/img/" + track.img + "_full.jpg"} alt={track.name} style={{maxWidth: `300px`, maxHeight: `300px`}}/><br/><br/> {albums}
</span>
<br/><br/>
{track.mbid && <a rel="noreferrer" target="_blank" href={"https://musicbrainz.org/track/" + track.mbid}>Open on MusicBrainz<br/></a>} {track.mbid && <a rel="noreferrer" target="_blank" href={"https://musicbrainz.org/track/" + track.mbid}>Open on MusicBrainz<br/></a>}
{track.spotify_id && <a rel="noreferrer" target="_blank" href={"https://open.spotify.com/track/" + track.spotify_id}>Open on Spotify<br/></a>} {track.spotify_id && <a rel="noreferrer" target="_blank" href={"https://open.spotify.com/track/" + track.spotify_id}>Open on Spotify<br/></a>}
Track Length: {length && length} {length && <span>Track Length: {length}</span>}
</div> </div>
<div style={{width: `600px`, padding: `0 10px 10px 10px`}}> <div style={{width: `290px`, padding: `0 10px 10px 10px`}}>
<h3>Top Users</h3> <h3>Top 10 Scrobblers</h3>
<br/> <TopUserTable uuid={track.uuid}/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -12,6 +12,7 @@
.modal { .modal {
font-size: 12px; font-size: 12px;
} }
.modal > .header { .modal > .header {
width: 100%; width: 100%;
border-bottom: 1px solid gray; border-bottom: 1px solid gray;