summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Makefile14
-rw-r--r--apiv1.go192
-rw-r--r--config.go34
-rw-r--r--data.go53
-rw-r--r--lfmclient.go47
-rw-r--r--main.go228
-rw-r--r--migration.sql17
-rw-r--r--modern.go2
-rw-r--r--schema.sql35
-rw-r--r--static/google.pngbin0 -> 4099 bytes
-rw-r--r--static/style.css154
-rw-r--r--templates/base.tmpl25
-rw-r--r--templates/index.tmpl15
-rw-r--r--templates/login.tmpl3
-rw-r--r--templates/settings.tmpl37
-rw-r--r--utils.go27
-rw-r--r--web.go132
17 files changed, 792 insertions, 223 deletions
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..0fa6116
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,14 @@
+all: kill build start
+
+build:
+ go build .
+
+kill:
+ -kill $$(cat pid)
+ -rm -f pid
+
+start:
+ ./lastfm & echo $$! > pid
+
+watch:
+ watchman-make -p '*.go' 'templates/*.tmpl' -t all
diff --git a/apiv1.go b/apiv1.go
new file mode 100644
index 0000000..f36f022
--- /dev/null
+++ b/apiv1.go
@@ -0,0 +1,192 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+func (app *App) mainHandler(w http.ResponseWriter, r *http.Request) {
+ r.ParseForm()
+ if _, ok := r.Form["hs"]; ok {
+
+ protocol := r.FormValue("p")
+ if protocol != "1.2.1" && protocol != "1.2" {
+ fmt.Fprintln(w, "FAILED Protocol mismatch")
+ return
+ }
+
+ timestamp := r.FormValue("t")
+ ts, err := strconv.ParseInt(timestamp, 10, 0)
+ if err != nil {
+ fmt.Fprintln(w, "FAILED Invalid timestamp")
+ return
+ }
+
+ delta := time.Now().Unix() - ts
+ if delta > 30 || delta < -30 {
+ fmt.Fprintln(w, "BADTIME")
+ return
+ }
+
+ user := r.FormValue("u")
+ auth := r.FormValue("a")
+ password, err := app.GetPassword(user)
+ if (md5hex(password+timestamp) != auth) || err != nil {
+ fmt.Fprintln(w, "BADAUTH")
+ return
+ }
+
+ client := r.FormValue("c")
+ s := NewSession(user, client, protocol)
+ app.PutSession(s)
+ fmt.Fprintln(w, "OK")
+ fmt.Fprintf(w, "%s\n", s.Key)
+ fmt.Fprintln(w, "http://post.audioscrobbler.com:80/np")
+ fmt.Fprintln(w, "http://post.audioscrobbler.com:80/scrobble")
+ } else {
+ fmt.Fprintf(w, "<html>This is an endpoint, see <a href=\"http://www.last.fm/api/submissions\">here</a></html>")
+ }
+}
+
+func parseValues(values url.Values) map[int]url.Values {
+ parts := make(map[int]url.Values)
+ var field string
+ for key, value := range values {
+ splitted_key := strings.SplitN(key, "[", 2)
+ if len(splitted_key) < 2 {
+ continue
+ }
+ field = splitted_key[0]
+ idx, err := strconv.Atoi(strings.TrimSuffix(splitted_key[1], "]"))
+ if err != nil {
+ continue
+ } else {
+ if _, ok := parts[idx]; !ok {
+ parts[idx] = make(url.Values)
+ }
+ parts[idx][field] = value
+ }
+ }
+ return parts
+}
+
+func parsePart1(values url.Values) (Scrobble, error) {
+ var scrobble Scrobble
+ scrobble.TrackName = NewCorrectable(values.Get("t"))
+ scrobble.Artist = NewCorrectable(values.Get("a"))
+ if t, err := strconv.Atoi(values.Get("i")); err != nil {
+ return scrobble, errors.New("Could not parse timestamp")
+ } else {
+ scrobble.Time = time.Unix(int64(t), 0)
+ }
+ scrobble.Album = NewCorrectable(values.Get("b"))
+ scrobble.Mbid = values.Get("m")
+ if tn, err := strconv.Atoi(values.Get("n")); err != nil {
+ return scrobble, errors.New("Could not parse track number")
+ } else {
+ scrobble.TrackNumber = tn
+ }
+ if duration, err := strconv.Atoi(values.Get("l")); err != nil {
+ return scrobble, errors.New("Could not parse duration")
+ } else {
+ scrobble.Duration = duration
+ }
+ chosen := values.Get("o")
+ if chosen == "P" || chosen == "" {
+ scrobble.Chosen = true
+ }
+ return scrobble, nil
+}
+
+func parsePart2(values url.Values) (Scrobble, error) {
+ var scrobble Scrobble
+ scrobble.TrackName = NewCorrectable(values.Get("track"))
+ scrobble.Artist = NewCorrectable(values.Get("artist"))
+ if t, err := strconv.Atoi(values.Get("timestamp")); err != nil {
+ return scrobble, errors.New("Could not parse timestamp")
+ } else {
+ scrobble.Time = time.Unix(int64(t), 0)
+ }
+ scrobble.Album = NewCorrectable(values.Get("album"))
+ scrobble.Mbid = values.Get("mbid")
+ if tn, ok := values["trackNumber"]; ok {
+ if tn, err := strconv.Atoi(tn[0]); err != nil {
+ return scrobble, errors.New("Could not parse track number")
+ } else {
+ scrobble.TrackNumber = tn
+ }
+ }
+ if duration, err := strconv.Atoi(values.Get("duration")); err != nil {
+ return scrobble, errors.New("Could not parse duration")
+ } else {
+ scrobble.Duration = duration
+ }
+ chosen := values.Get("chosenByUser")
+ if chosen == "1" || chosen == "" {
+ scrobble.Chosen = true
+ }
+ return scrobble, nil
+}
+
+func parseScrobbles(values url.Values, session *Session) ([]Scrobble, int) {
+ scrobbles := make([]Scrobble, 0, 1)
+ parts := parseValues(values)
+ var parsePart func(url.Values) (Scrobble, error)
+ if session.Protocol == "2.0" {
+ parsePart = parsePart2
+ } else {
+ parsePart = parsePart1
+ }
+ ignored := 0
+ for i, c := 0, 0; i < 50 && c < len(parts); i++ {
+ if part, ok := parts[i]; ok {
+ c++
+ if scrobble, err := parsePart(part); err != nil {
+ fmt.Printf("%v\n", err)
+ ignored++
+ } else {
+ scrobble.Session = session.Key
+ scrobbles = append(scrobbles, scrobble)
+ }
+ }
+ }
+ return scrobbles, ignored
+}
+
+func (app *App) scrobbleHandler(w http.ResponseWriter, r *http.Request) {
+ if session, err := app.GetSession(r.FormValue("s")); err != nil {
+ fmt.Fprintln(w, "BADSESSION")
+ } else {
+ scrobbles, _ := parseScrobbles(r.Form, session)
+
+ for i, s := range scrobbles {
+ t := app.TrackInfo(s.TrackName.Name, s.Artist.Name)
+ s.Mbid = t.Mbid
+ scrobbles[i] = s
+ _, err = app.DB.Exec("INSERT INTO songs VALUES ($1, $2) "+
+ "ON CONFLICT (mbid) DO UPDATE SET mbid=$1, image=$2",
+ t.Mbid, t.GetImage("medium"))
+ if err != nil {
+ log.Println(err)
+ }
+ }
+
+ fmt.Printf("%v\n", scrobbles)
+ app.PutScrobbles(scrobbles)
+ fmt.Fprintln(w, "OK")
+ }
+}
+
+func (app *App) nowPlayingHandler(w http.ResponseWriter, r *http.Request) {
+ if _, err := app.GetSession(r.FormValue("s")); err != nil {
+ fmt.Fprintln(w, "BADSESSION")
+ } else {
+ fmt.Fprintln(w, "OK")
+ }
+}
diff --git a/config.go b/config.go
new file mode 100644
index 0000000..0f0b853
--- /dev/null
+++ b/config.go
@@ -0,0 +1,34 @@
+package main
+
+import (
+ "encoding/json"
+ "io/ioutil"
+ "log"
+
+ "golang.org/x/oauth2"
+ "golang.org/x/oauth2/google"
+)
+
+type Config struct {
+ OAuth oauth2.Config
+ HashKey string
+ BlockKey string
+ Database string
+ Lfm LfmConfig
+}
+
+type LfmConfig struct {
+ ApiKey string
+ SharedSecret string
+}
+
+func NewConfig() *Config {
+ f, err := ioutil.ReadFile("config.json")
+ if err != nil {
+ log.Fatal(err)
+ }
+ conf := new(Config)
+ json.Unmarshal(f, conf)
+ conf.OAuth.Endpoint = google.Endpoint
+ return conf
+}
diff --git a/data.go b/data.go
index 065e891..4e28750 100644
--- a/data.go
+++ b/data.go
@@ -4,6 +4,7 @@ import (
"database/sql"
"encoding/xml"
"fmt"
+ "log"
"time"
_ "github.com/mattn/go-sqlite3"
@@ -16,10 +17,11 @@ type Scrobble struct {
Album Correctable `xml:"album" json:"album"`
TrackNumber int `xml:"-" json:"-"`
Duration int `xml:"-" json:"-"`
- Time int `xml:"timestamp" json:"timestamp,string"`
+ Time time.Time `xml:"timestamp" json:"timestamp,string"`
Chosen bool `xml:"-" json:"-"`
Mbid string `xml:"-" json:"-"`
Session string `xml:"-" json:"-"`
+ Image string
}
type Session struct {
@@ -28,7 +30,7 @@ type Session struct {
Key string `json:"key" xml:"key"`
Client string
Protocol string
- Created int64
+ Created time.Time
Subscriber int64 `json:"subscriber" xml:"subscriber"`
}
@@ -37,6 +39,7 @@ type DataStore interface {
GetSession(key string) (*Session, error)
GetPassword(userName string) (string, error)
PutScrobbles([]Scrobble)
+ RecentScrobbles(lfmName string) []*Scrobble
Api
}
@@ -44,43 +47,34 @@ type SqlStore struct {
*sql.DB
}
-func NewSqlStore() *SqlStore {
- db, err := sql.Open("sqlite3", "./test.db")
- if err != nil {
- fmt.Println(err)
- }
- err = db.Ping()
- if err != nil {
- fmt.Println(err)
- }
- return &SqlStore{db}
-}
-
func NewSession(user string, client string, protocol string) *Session {
return &Session{
User: user,
Key: randomToken(16),
Client: client,
Protocol: protocol,
- Created: time.Now().Unix(),
+ Created: time.Now(),
}
}
func (store *SqlStore) PutSession(s *Session) {
- store.Exec("INSERT INTO sessions VALUES (?, ?, ?, ?, ?)",
+ _, err := store.Exec("INSERT INTO scrobbling_sessions VALUES ($1, $2, $3, $4, $5)",
s.User, s.Key, s.Client, s.Protocol, s.Created)
+ if err != nil {
+ log.Println(err)
+ }
}
func (store *SqlStore) GetSession(key string) (*Session, error) {
s := &Session{}
- row := store.QueryRow("SELECT * FROM sessions WHERE key = ?", key)
+ row := store.QueryRow("SELECT * FROM scrobbling_sessions WHERE key = $1", key)
err := row.Scan(&s.User, &s.Key, &s.Client, &s.Protocol, &s.Created)
return s, err
}
func (store *SqlStore) GetPassword(name string) (string, error) {
var password string
- row := store.QueryRow("SELECT password FROM users WHERE name = ?", name)
+ row := store.QueryRow("SELECT lfm_password FROM users WHERE lfm_name = $1", name)
err := row.Scan(&password)
return password, err
}
@@ -88,7 +82,7 @@ func (store *SqlStore) GetPassword(name string) (string, error) {
func (store *SqlStore) PutScrobbles(scrobbles []Scrobble) {
for _, s := range scrobbles {
if _, err := store.Exec(
- "INSERT INTO scrobbles VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO scrobbles VALUES ($1, $2, $3, $4,$5, $6, $7, $8, $9, $10)",
s.Artist,
s.AlbumArtist,
s.TrackName,
@@ -104,3 +98,24 @@ func (store *SqlStore) PutScrobbles(scrobbles []Scrobble) {
}
}
}
+
+func (store *SqlStore) RecentScrobbles(lfmName string) []*Scrobble {
+ scrobbles := make([]*Scrobble, 0, 10)
+ rows, err := store.Query("SELECT artist, album, trackname, time, image "+
+ "FROM scrobbles JOIN scrobbling_sessions ON session = key "+
+ "LEFT JOIN songs ON scrobbles.mbid = songs.mbid "+
+ "WHERE lfm_name=$1 ORDER BY time DESC LIMIT 10",
+ lfmName)
+ if err != nil {
+ log.Println(err)
+ }
+
+ for rows.Next() {
+ scrobble := new(Scrobble)
+ rows.Scan(&scrobble.Artist.Name, &scrobble.Album.Name,
+ &scrobble.TrackName.Name, &scrobble.Time, &scrobble.Image)
+ scrobbles = append(scrobbles, scrobble)
+ }
+ rows.Close()
+ return scrobbles
+}
diff --git a/lfmclient.go b/lfmclient.go
new file mode 100644
index 0000000..244ab8c
--- /dev/null
+++ b/lfmclient.go
@@ -0,0 +1,47 @@
+package main
+
+import (
+ "encoding/json"
+ "io/ioutil"
+ "net/http"
+)
+
+type AlbumImage struct {
+ Size string `json:"size"`
+ Href string `json:"#text"`
+}
+
+type TrackAlbum struct {
+ Images []AlbumImage `json:"image"`
+}
+
+func (t TrackGetInfo) GetImage(size string) string {
+ images := t.Album.Images
+ for _, image := range images {
+ if image.Size == size {
+ return image.Href
+ }
+ }
+ return "https://lastfm-img2.akamaized.net/i/u/64s/4128a6eb29f94943c9d206c08e625904.png"
+}
+
+type TrackGetInfo struct {
+ Mbid string `json:"mbid"`
+ Album TrackAlbum `jon:"album"`
+}
+
+func (app *App) TrackInfo(track string, artist string) TrackGetInfo {
+ r, _ := http.NewRequest("GET", "http://ws.audioscrobbler.com/2.0/", nil)
+ values := r.URL.Query()
+ values.Add("method", "track.getInfo")
+ values.Add("api_key", app.Config.Lfm.ApiKey)
+ values.Add("artist", artist)
+ values.Add("track", track)
+ values.Add("format", "json")
+ r.URL.RawQuery = values.Encode()
+ resp, _ := http.DefaultClient.Do(r)
+ body, _ := ioutil.ReadAll(resp.Body)
+ var dst map[string]TrackGetInfo
+ json.Unmarshal(body, &dst)
+ return dst["track"]
+}
diff --git a/main.go b/main.go
index de5ba2a..4065dad 100644
--- a/main.go
+++ b/main.go
@@ -1,199 +1,75 @@
package main
import (
- "errors"
+ "database/sql"
+ "encoding/hex"
"fmt"
+ "html/template"
+ "log"
"net/http"
"net/http/httputil"
- "net/url"
- "strconv"
- "strings"
- "time"
-)
-
-func mainHandler(ds DataStore, w http.ResponseWriter, r *http.Request) {
- r.ParseForm()
- if _, ok := r.Form["hs"]; ok {
-
- protocol := r.FormValue("p")
- if protocol != "1.2.1" && protocol != "1.2" {
- fmt.Fprintln(w, "FAILED Protocol mismatch")
- return
- }
-
- timestamp := r.FormValue("t")
- ts, err := strconv.ParseInt(timestamp, 10, 0)
- if err != nil {
- fmt.Fprintln(w, "FAILED Invalid timestamp")
- return
- }
-
- delta := time.Now().Unix() - ts
- if delta > 30 || delta < -30 {
- fmt.Fprintln(w, "BADTIME")
- return
- }
-
- user := r.FormValue("u")
- auth := r.FormValue("a")
- password, err := ds.GetPassword(user)
- if (md5hex(password+timestamp) != auth) || err != nil {
- fmt.Fprintln(w, "BADAUTH")
- return
- }
-
- client := r.FormValue("c")
- s := NewSession(user, client, protocol)
- ds.PutSession(s)
- fmt.Fprintln(w, "OK")
- fmt.Fprintf(w, "%s\n", s.Key)
- fmt.Fprintln(w, "http://post.audioscrobbler.com:80/np")
- fmt.Fprintln(w, "http://post.audioscrobbler.com:80/scrobble")
- } else {
- fmt.Fprintf(w, "<html>This is an endpoint, see <a href=\"http://www.last.fm/api/submissions\">here</a></html>")
- }
-}
-
-func parseValues(values url.Values) map[int]url.Values {
- parts := make(map[int]url.Values)
- var field string
- for key, value := range values {
- splitted_key := strings.SplitN(key, "[", 2)
- if len(splitted_key) < 2 {
- continue
- }
- field = splitted_key[0]
- idx, err := strconv.Atoi(strings.TrimSuffix(splitted_key[1], "]"))
- if err != nil {
- continue
- } else {
- if _, ok := parts[idx]; !ok {
- parts[idx] = make(url.Values)
- }
- parts[idx][field] = value
- }
- }
- return parts
-}
-
-func parsePart1(values url.Values) (Scrobble, error) {
- var scrobble Scrobble
- scrobble.TrackName = NewCorrectable(values.Get("t"))
- scrobble.Artist = NewCorrectable(values.Get("a"))
- if time, err := strconv.Atoi(values.Get("i")); err != nil {
- return scrobble, errors.New("Could not parse timestamp")
- } else {
- scrobble.Time = time
- }
- scrobble.Album = NewCorrectable(values.Get("b"))
- scrobble.Mbid = values.Get("m")
- if tn, err := strconv.Atoi(values.Get("n")); err != nil {
- return scrobble, errors.New("Could not parse track number")
- } else {
- scrobble.TrackNumber = tn
- }
- if duration, err := strconv.Atoi(values.Get("l")); err != nil {
- return scrobble, errors.New("Could not parse duration")
- } else {
- scrobble.Duration = duration
- }
- chosen := values.Get("o")
- if chosen == "P" || chosen == "" {
- scrobble.Chosen = true
- }
- return scrobble, nil
-}
-func parsePart2(values url.Values) (Scrobble, error) {
- var scrobble Scrobble
- scrobble.TrackName = NewCorrectable(values.Get("track"))
- scrobble.Artist = NewCorrectable(values.Get("artist"))
- if time, err := strconv.Atoi(values.Get("timestamp")); err != nil {
- return scrobble, errors.New("Could not parse timestamp")
- } else {
- scrobble.Time = time
- }
- scrobble.Album = NewCorrectable(values.Get("album"))
- scrobble.Mbid = values.Get("mbid")
- if tn, ok := values["trackNumber"]; ok {
- if tn, err := strconv.Atoi(tn[0]); err != nil {
- return scrobble, errors.New("Could not parse track number")
- } else {
- scrobble.TrackNumber = tn
- }
- }
- if duration, err := strconv.Atoi(values.Get("duration")); err != nil {
- return scrobble, errors.New("Could not parse duration")
- } else {
- scrobble.Duration = duration
- }
- chosen := values.Get("chosenByUser")
- if chosen == "1" || chosen == "" {
- scrobble.Chosen = true
- }
- return scrobble, nil
-}
+ "github.com/gorilla/securecookie"
+)
-func parseScrobbles(values url.Values, session *Session) ([]Scrobble, int) {
- scrobbles := make([]Scrobble, 0, 1)
- parts := parseValues(values)
- var parsePart func(url.Values) (Scrobble, error)
- if session.Protocol == "2.0" {
- parsePart = parsePart2
- } else {
- parsePart = parsePart1
- }
- ignored := 0
- for i, c := 0, 0; i < 50 && c < len(parts); i++ {
- if part, ok := parts[i]; ok {
- c++
- if scrobble, err := parsePart(part); err != nil {
- fmt.Printf("%v\n", err)
- ignored++
- } else {
- scrobble.Session = session.Key
- scrobbles = append(scrobbles, scrobble)
- }
- }
- }
- return scrobbles, ignored
-}
+type handler func(DataStore, http.ResponseWriter, *http.Request)
-func scrobbleHandler(ds DataStore, w http.ResponseWriter, r *http.Request) {
- if session, err := ds.GetSession(r.FormValue("s")); err != nil {
- fmt.Fprintln(w, "BADSESSION")
- } else {
- scrobbles, _ := parseScrobbles(r.Form, session)
- fmt.Printf("%v\n", scrobbles)
- ds.PutScrobbles(scrobbles)
- fmt.Fprintln(w, "OK")
- }
+type App struct {
+ DataStore
+ DB *sql.DB
+ Config *Config
+ CookieHandler *securecookie.SecureCookie
+ Template *template.Template
}
-func nowPlayingHandler(ds DataStore, w http.ResponseWriter, r *http.Request) {
- if _, err := ds.GetSession(r.FormValue("s")); err != nil {
- fmt.Fprintln(w, "BADSESSION")
- } else {
- fmt.Fprintln(w, "OK")
+func New() *App {
+ app := new(App)
+ config := NewConfig()
+ app.Config = config
+ db, err := sql.Open("postgres", config.Database)
+ if err = db.Ping(); err != nil {
+ log.Fatal(err)
}
+ app.DB = db
+ app.DataStore = &SqlStore{db}
+ hashKey, err := hex.DecodeString(config.HashKey)
+ blockKey, err := hex.DecodeString(config.HashKey)
+ s := securecookie.New(hashKey, blockKey)
+ app.CookieHandler = s
+ templates := template.Must(template.ParseGlob("templates/*.tmpl"))
+ app.Template = templates
+ return app
}
-type handler func(DataStore, http.ResponseWriter, *http.Request)
-
-func wrap(ds DataStore, fn handler) http.HandlerFunc {
+func logg(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
d, _ := httputil.DumpRequest(r, true)
fmt.Printf("--------------------------\n%s\n", d)
r.ParseForm()
- fn(ds, w, r)
+ fn(w, r)
}
}
func main() {
- store := NewSqlStore()
- http.HandleFunc("/", wrap(store, handler(mainHandler)))
- http.HandleFunc("/np", wrap(store, nowPlayingHandler))
- http.HandleFunc("/scrobble", wrap(store, scrobbleHandler))
- http.HandleFunc("/2.0/", wrap(store, ApiHandler))
- http.ListenAndServe(":3001", nil)
+ app := New()
+
+ go func() {
+ sm := http.NewServeMux()
+ sm.HandleFunc("/", logg(app.mainHandler))
+ sm.HandleFunc("/np", logg(app.nowPlayingHandler))
+ sm.HandleFunc("/scrobble", logg(app.scrobbleHandler))
+ //http.HandleFunc("/2.0/", logg(app.ApiHandler))
+ http.ListenAndServe(":3001", sm)
+ }()
+
+ app.TrackInfo("believe", "cher")
+
+ sm := http.NewServeMux()
+ fs := http.FileServer(http.Dir("static"))
+ sm.Handle("/static/", http.StripPrefix("/static/", fs))
+ sm.HandleFunc("/", app.root)
+ sm.HandleFunc("/callback", app.callback)
+ sm.HandleFunc("/login", app.login)
+ sm.HandleFunc("/settings", app.settings)
+ http.ListenAndServe(":8000", sm)
}
diff --git a/migration.sql b/migration.sql
deleted file mode 100644
index cd95209..0000000
--- a/migration.sql
+++ /dev/null
@@ -1,17 +0,0 @@
-BEGIN;
-
- CREATE TABLE new_sessions (
- user string,
- key string PRIMARY KEY,
- client string,
- protocol string,
- created int
- );
-
- INSERT INTO new_sessions SELECT * FROM sessions;
-
- DROP TABLE sessions;
-
- ALTER TABLE new_sessions RENAME TO sessions;
-
-END;
diff --git a/modern.go b/modern.go
index 45c92bd..6277796 100644
--- a/modern.go
+++ b/modern.go
@@ -59,7 +59,7 @@ func NewCorrectable(name string) Correctable {
}
}
-func (field *Correctable) String() string {
+func (field Correctable) String() string {
return field.Name
}
diff --git a/schema.sql b/schema.sql
index 72211ee..fb13dc7 100644
--- a/schema.sql
+++ b/schema.sql
@@ -1,25 +1,40 @@
-CREATE TABLE users (
- name text PRIMARY KEY,
- password text
-);
-
-CREATE TABLE sessions (
- user text,
+CREATE TABLE IF NOT EXISTS scrobbling_sessions (
+ lfm_name text,
key text PRIMARY KEY,
client text,
protocol text,
- created int DEFAULT (strftime('%s', 'now'))
+ created timestamptz DEFAULT current_timestamp
);
-CREATE TABLE scrobbles (
+CREATE TABLE IF NOT EXISTS scrobbles (
artist text,
albumartist text,
trackname text,
album text,
tracknumber int,
duration int,
- time int,
+ time timestamptz,
chosen bool,
mbid text,
session text
);
+
+CREATE TABLE IF NOT EXISTS users (
+ user_id SERIAL PRIMARY KEY,
+ type text,
+ op_id text,
+ name text,
+ email text,
+ lfm_name text,
+ lfm_password text
+);
+
+CREATE TABLE IF NOT EXISTS user_sessions (
+ id text PRIMARY KEY,
+ user_id integer REFERENCES users
+);
+
+CREATE TABLE IF NOT EXISTS songs (
+ mbid text PRIMARY KEY,
+ image text
+);
diff --git a/static/google.png b/static/google.png
new file mode 100644
index 0000000..29ab511
--- /dev/null
+++ b/static/google.png
Binary files differ
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..fca47c8
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,154 @@
+body {
+ font-family: 'Roboto';
+ font-size: 16px;
+ margin: 0;
+}
+
+a {
+ text-decoration: none;
+}
+
+header {
+ height: 50px;
+ background-color: #3F51B5;
+ box-sizing: border-box;
+ color: white;
+ margin-bottom: 1em;
+ box-shadow: 0 0 4px #1A237E;
+}
+
+h1 {
+ font-size: 24px;
+ font-weight: 500;
+ margin: 0;
+ padding: 0;
+}
+
+h1 > a {
+ color: white;
+}
+
+h2 {
+ font-weight: 500;
+ font-size: 20px;
+ margin: 16px;
+}
+
+ul.scrobbles {
+ padding: 0;
+ margin: 0;
+ list-style: none;
+}
+
+ul.scrobbles li {
+ display: flex;
+ height: 60px;
+ padding: 0 16px;
+ align-items: center;
+}
+
+ul.scrobbles li:hover {
+ background-color: #E8EAF6;
+}
+
+ul.scrobbles li img {
+ height: 40px;
+ margin-right: 16px;
+ border: 1px solid #C5CAE9;
+}
+
+ul.scrobbles li div {
+ font-size: 14px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 18px;
+}
+
+ul.scrobbles li .album {
+ font-size: 12px;
+ color: #4f4f4f;
+}
+
+#center {
+ height: 100%;
+ max-width: 1000px;
+ margin: 0 auto;
+ padding: 0 16px;
+ display: flex;
+ box-sizing: border-box;
+ align-items: center;
+}
+
+.user {
+ color: rgba(255, 255, 255, .8);
+ margin-left: auto;
+}
+
+.user:hover {
+ color: rgba(255, 255, 255, 1);
+}
+
+#main {
+ max-width: 1000px;
+ margin: 0 auto;
+}
+
+input[type=text], input[type=password] {
+ border: none;
+ padding-bottom: 8px;
+ font-size: 16px;
+ border-bottom: 1px solid grey;
+}
+
+label {
+ font-size: 12px;
+ box-sizing: border-box;
+ margin-bottom: 8px;
+}
+
+input[type=text]:focus, input[type=password]:focus {
+ outline: none;
+ padding-bottom: 7px;
+ border-bottom: 2px solid #3F51B5;
+}
+
+input:focus+label {
+ color: #3F51B5;
+
+}
+
+.row {
+ display: flex;
+ margin-bottom: 1em;
+ flex-wrap: wrap;
+ margin-top: -1em;
+ padding: 0 16px;
+}
+
+.form-element {
+ display: flex;
+ flex-direction: column-reverse;
+ margin-right:1em;
+ margin-top: 1em;
+ width: 300px;
+}
+
+input[type=submit] {
+ border: none;
+ background: none;
+ padding: 0;
+ font-family: 'Roboto';
+ color: #3F51B5;
+ font-size: 16px;
+ font-weight: 500;
+ cursor: pointer
+}
+
+input[type=submit]:focus {
+ outline: none;
+}
+
+form > section {
+ margin-bottom: 2em;
+}
diff --git a/templates/base.tmpl b/templates/base.tmpl
new file mode 100644
index 0000000..2ee087a
--- /dev/null
+++ b/templates/base.tmpl
@@ -0,0 +1,25 @@
+{{define "header"}}
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>ListenDiary</title>
+ <link href="https://fonts.googleapis.com/css?family=Roboto:400,500,700" rel="stylesheet">
+ <link rel="stylesheet" href="static/style.css">
+ </head>
+ <body>
+<header>
+ <div id="center">
+ <h1><a href="/">ListenDiary</a></h1>
+ <a href="/settings" class="user">{{ .Session.UserName }}</a>
+ </div>
+</header>
+<div id="main">
+{{end}}
+
+{{define "footer"}}
+</div>
+ </body>
+</html>
+{{end}}
diff --git a/templates/index.tmpl b/templates/index.tmpl
new file mode 100644
index 0000000..7ee1801
--- /dev/null
+++ b/templates/index.tmpl
@@ -0,0 +1,15 @@
+{{template "header" . }}
+<h2>Recent Listens</h2>
+<ul class="scrobbles">
+{{range .Scrobbles}}
+ <li>
+ <img src="{{or .Image "https://lastfm-img2.akamaized.net/i/u/64s/4128a6eb29f94943c9d206c08e625904.png"}}"
+ height="40"/>
+ <div>
+ {{.Artist}} — {{.TrackName}}<br/>
+ <span class="album">in {{.Album}}</span>
+ </div>
+ </li>
+{{end}}
+</ul>
+{{template "footer" }}
diff --git a/templates/login.tmpl b/templates/login.tmpl
new file mode 100644
index 0000000..1510242
--- /dev/null
+++ b/templates/login.tmpl
@@ -0,0 +1,3 @@
+{{template "header"}}
+<a href="{{.}}"><img src="static/google.png"/></a>
+{{template "footer"}}
diff --git a/templates/settings.tmpl b/templates/settings.tmpl
new file mode 100644
index 0000000..5c757ee
--- /dev/null
+++ b/templates/settings.tmpl
@@ -0,0 +1,37 @@
+{{template "header" .}}
+<form method=post action="/settings">
+<section>
+<h2>Profile</h2>
+ <div class="row">
+ <div class="form-element">
+ <input name="name" id="name" type="text" value={{.UserName}}>
+ <label for="name">Name</label>
+ </div>
+
+ <div class="form-element">
+ <input name="email" id="email" type="text" value={{.Email}}>
+ <label for="email">Email</label>
+ </div>
+ </div>
+</section>
+
+<section>
+<h2>Last.fm</h2>
+ <div class="row">
+ <div class="form-element">
+ <input name="lfm_name" id="lfm_name" type="text" value={{.LfmName}}>
+ <label for="lfm_name">Username</label>
+ </div>
+
+ <div class="form-element">
+ <input name="lfm_password" id="lfm_password" type="password" value={{.LfmPassword}}>
+ <label for="lfm_password">Password</label>
+ </div>
+ </div>
+
+</section>
+<div class="row">
+<input type="submit" value="SAVE">
+</div>
+</form>
+{{template "footer"}}
diff --git a/utils.go b/utils.go
index cb29662..49fdc5b 100644
--- a/utils.go
+++ b/utils.go
@@ -4,6 +4,7 @@ import (
"crypto/md5"
"crypto/rand"
"encoding/hex"
+ "net/http"
)
func randomToken(length int) string {
@@ -12,7 +13,33 @@ func randomToken(length int) string {
return hex.EncodeToString(b)
}
+func genKey(length int) []byte {
+ b := make([]byte, length)
+ rand.Read(b)
+ return b
+}
+
func md5hex(s string) string {
hash := md5.Sum([]byte(s))
return hex.EncodeToString(hash[:])
}
+
+func (app *App) GetCookie(r *http.Request, name string, dst interface{}) error {
+ cookie, err := r.Cookie(name)
+ if err != nil {
+ return err
+ }
+ return app.CookieHandler.Decode(name, cookie.Value, dst)
+}
+
+func (app *App) SetCookie(w http.ResponseWriter, name string, v interface{}, exp int) {
+ encoded, _ := app.CookieHandler.Encode(name, v)
+ cookie := &http.Cookie{
+ Name: name,
+ Value: encoded,
+ Path: "/",
+ HttpOnly: true,
+ MaxAge: exp,
+ }
+ http.SetCookie(w, cookie)
+}
diff --git a/web.go b/web.go
new file mode 100644
index 0000000..c9d27b1
--- /dev/null
+++ b/web.go
@@ -0,0 +1,132 @@
+package main
+
+import (
+ "context"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "log"
+ "net/http"
+
+ _ "github.com/lib/pq"
+)
+
+type UserInfo struct {
+ Sub string `json:"sub"`
+ UserName string `json:"given_name"`
+ Email string `json:"email"`
+}
+
+type UserSession struct {
+ Id string
+ UserId int64
+ UserName string
+}
+
+func (app *App) login(w http.ResponseWriter, r *http.Request) {
+ state := hex.EncodeToString(genKey(32))
+ app.SetCookie(w, "state", state, 120)
+ url := app.Config.OAuth.AuthCodeURL(state)
+ app.Template.ExecuteTemplate(w, "login.tmpl", url)
+}
+
+func (app *App) root(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ se := new(UserSession)
+ err := app.GetCookie(r, "session", se)
+ if err != nil {
+ http.Redirect(w, r, "/login", http.StatusFound)
+ return
+ }
+
+ var lfmName string
+ row := app.DB.QueryRow("SELECT lfm_name FROM users WHERE user_id=$1",
+ se.UserId)
+ row.Scan(&lfmName)
+ scrobbles := app.RecentScrobbles(lfmName)
+
+ app.Template.ExecuteTemplate(w, "index.tmpl", struct {
+ Session *UserSession
+ Scrobbles []*Scrobble
+ }{se, scrobbles})
+}
+
+func (app *App) callback(w http.ResponseWriter, r *http.Request) {
+ redir := func(err error) {
+ if err != nil {
+ http.Redirect(w, r, "/login", http.StatusFound)
+ log.Panic(err)
+ }
+ }
+
+ var state string
+ app.GetCookie(r, "state", &state)
+ if state == "" || state != r.FormValue("state") {
+ redir(fmt.Errorf("state"))
+ }
+ code := r.FormValue("code")
+ tok, err := app.Config.OAuth.Exchange(context.Background(), code)
+ redir(err)
+ client := app.Config.OAuth.Client(context.Background(), tok)
+ resp, _ := client.Get("https://www.googleapis.com/plus/v1/people/me/openIdConnect")
+ p, _ := ioutil.ReadAll(resp.Body)
+ userinfo := new(UserInfo)
+ err = json.Unmarshal(p, userinfo)
+ redir(err)
+
+ se := new(UserSession)
+ se.Id = hex.EncodeToString(genKey(32))
+ row := app.DB.QueryRow("SELECT user_id, name FROM users WHERE type='google' AND op_id=$1",
+ userinfo.Sub)
+ err = row.Scan(&se.UserId, &se.UserName)
+ if err != nil {
+ row := app.DB.QueryRow("INSERT into users (type, op_id, name, email)"+
+ "values ('google', $1, $2, $3) RETURNING user_id",
+ userinfo.Sub, userinfo.UserName, userinfo.Email)
+ row.Scan(&se.UserId)
+ se.UserName = userinfo.UserName
+ }
+ app.DB.Exec("INSERT into user_sessions values ($1, $2)", se.Id, se.UserId)
+ app.SetCookie(w, "session", se, 86400*30)
+ if err != nil {
+ http.Redirect(w, r, "/settings", http.StatusTemporaryRedirect)
+ return
+ }
+ http.Redirect(w, r, "/", http.StatusFound)
+}
+
+func (app *App) settings(w http.ResponseWriter, r *http.Request) {
+ se := new(UserSession)
+ err := app.GetCookie(r, "session", se)
+ if err != nil {
+ http.Redirect(w, r, "/login", http.StatusFound)
+ return
+ }
+
+ if r.Method == "POST" {
+ _, err = app.DB.Exec("UPDATE users SET name=$1, email=$2, lfm_name=$3, lfm_password=$4 WHERE user_id=$5",
+ r.FormValue("name"), r.FormValue("email"), r.FormValue("lfm_name"),
+ md5hex(r.FormValue("lfm_password")), se.UserId)
+ if err != nil {
+ log.Println(err)
+ }
+ se.UserName = r.FormValue("name")
+ app.SetCookie(w, "session", se, 86400*30)
+ }
+
+ var userName, email, lfmName, lfmPassword string
+ row := app.DB.QueryRow("SELECT name, email, lfm_name, lfm_password FROM users WHERE user_id=$1",
+ se.UserId)
+ row.Scan(&userName, &email, &lfmName, &lfmPassword)
+ app.Template.ExecuteTemplate(w, "settings.tmpl", struct {
+ Session *UserSession
+ UserName string
+ Email string
+ LfmName string
+ LfmPassword string
+ }{se, userName, email, lfmName, lfmPassword})
+}