You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
274 lines
7.4 KiB
274 lines
7.4 KiB
package main |
|
|
|
import ( |
|
"bytes" |
|
"context" |
|
"crawshaw.io/sqlite/sqlitex" |
|
"encoding/json" |
|
"errors" |
|
"flag" |
|
"golang.org/x/crypto/acme/autocert" |
|
"html/template" |
|
"io" |
|
"log" |
|
"net/http" |
|
"strconv" |
|
"time" |
|
) |
|
|
|
var databasePath = flag.String("database", "./data.sqlite3", "database path") |
|
var pageTemplate = `{{ define "base" }} |
|
<!DOCTYPE html> |
|
<html lang='en'> |
|
<head> |
|
<title>etascrobbler</title> |
|
<link rel='stylesheet' type='text/css' href='//eta.st/assets/webfonts/stylesheet.css'> |
|
<link rel='stylesheet' type='text/css' href='//eta.st/css/main.css'> |
|
<meta name='viewport' content='width=device-width,initial-scale=1'> |
|
</head> |
|
<header class='site-header'> |
|
<div class='wrapper'> |
|
<a class='site-title' href='/'> |
|
<img src='//eta.st/assets/img/logo.svg' alt='η'>(eta) |
|
</a> |
|
<nav class='site-nav'> |
|
<div class='trigger'>etascrobbler</div> |
|
</nav> |
|
</div> |
|
</header> |
|
<div class='page-content'> |
|
<div class='wrapper'> |
|
<h1>now playing</h1> |
|
<p> |
|
This shows what I was last listening to on any of my devices, and the song before that. This tiny webapp has observed <b>{{ .TotalSongs }}</b> |
|
songs being played so far. |
|
</p> |
|
<p>This site is very beta. More information should be added shortly!</p> |
|
{{if .SomethingPlaying}} |
|
<div class="book-meta"> |
|
<p class="book-title">🎵 {{ .CurrentTrack.Artist }} — {{ .CurrentTrack.Name }} |
|
</div> |
|
{{end}} |
|
<p> |
|
<i>Previously: </i> |
|
{{ .PreviousTrack.Artist }} — {{ .PreviousTrack.Name }} |
|
</p> |
|
<p class='detail'> |
|
Artists (<strong>{{ .TotalArtists }}</strong>): {{range .Artists}}{{.}} · {{end}} |
|
</p> |
|
</div> |
|
</div> |
|
</html> |
|
{{ end }} |
|
` |
|
var tmpl *template.Template = template.Must(template.New("base").Parse(pageTemplate)) |
|
var dbpool *sqlitex.Pool |
|
|
|
type Scrobble struct { |
|
Artist string `json:"artist"` |
|
Name string `json:"track"` |
|
Ts time.Time |
|
} |
|
|
|
var currentScrobble *Scrobble |
|
|
|
func getLastScrobble() (s *Scrobble, err error) { |
|
conn := dbpool.Get(context.Background()) |
|
if conn == nil { |
|
err = errors.New("no connection whoopsie") |
|
return s, err |
|
} |
|
defer dbpool.Put(conn) |
|
stmt := conn.Prep("SELECT artist, title FROM plays ORDER BY ts DESC LIMIT 1") |
|
hasRow, err := stmt.Step() |
|
if hasRow { |
|
s = &Scrobble{ |
|
Artist: stmt.GetText("artist"), |
|
Name: stmt.GetText("title"), |
|
// FIXME(eta): lol don't actually set `ts` because who cares |
|
} |
|
stmt.Step() |
|
return s, nil |
|
} else { |
|
return s, nil |
|
} |
|
} |
|
|
|
func getAllArtists() (artists []string, err error) { |
|
conn := dbpool.Get(context.Background()) |
|
if conn == nil { |
|
err = errors.New("no connection whoopsie") |
|
return nil, err |
|
} |
|
defer dbpool.Put(conn) |
|
artists = make([]string, 0) |
|
stmt := conn.Prep("SELECT DISTINCT artist FROM plays ORDER BY artist ASC") |
|
for { |
|
hasRow, err := stmt.Step() |
|
if err != nil { |
|
return nil, err |
|
} else if hasRow { |
|
artists = append(artists, stmt.GetText("artist")) |
|
} else { |
|
break |
|
} |
|
} |
|
return artists, nil |
|
} |
|
|
|
func countAllScrobbles() (count int64, err error) { |
|
conn := dbpool.Get(context.Background()) |
|
if conn == nil { |
|
err = errors.New("no connection whoopsie") |
|
return -1, err |
|
} |
|
defer dbpool.Put(conn) |
|
stmt := conn.Prep("SELECT COUNT(*) AS c FROM plays") |
|
hasRow, err := stmt.Step() |
|
if hasRow { |
|
count = stmt.GetInt64("c") |
|
stmt.Step() |
|
return count, nil |
|
} else { |
|
err = errors.New("wut") |
|
return -1, nil |
|
} |
|
} |
|
|
|
type ListenBrainzPayload struct { |
|
ListenedAt interface{} `json:"listened_at"` |
|
TrackMetadata struct { |
|
ArtistName string `json:"artist_name"` |
|
ReleaseName string `json:"release_name"` |
|
TrackName string `json:"track_name"` |
|
} `json:"track_metadata"` |
|
} |
|
|
|
func (payload *ListenBrainzPayload) fudgeListenedAt() int64 { |
|
data, ok := payload.ListenedAt.(string) |
|
if ok { |
|
ret, _ := strconv.Atoi(data) |
|
return int64(ret) |
|
} |
|
data2, ok := payload.ListenedAt.(int64) |
|
if ok { |
|
return data2 |
|
} |
|
return 0 |
|
} |
|
|
|
func insertTrack(track ListenBrainzPayload) (err error) { |
|
conn := dbpool.Get(context.Background()) |
|
if conn == nil { |
|
err = errors.New("no connection whoopsie") |
|
return |
|
} |
|
defer dbpool.Put(conn) |
|
stmt := conn.Prep("INSERT INTO plays (artist, title, ts) VALUES (?, ?, ?)") |
|
stmt.BindText(1, track.TrackMetadata.ArtistName) |
|
stmt.BindText(2, track.TrackMetadata.TrackName) |
|
stmt.BindInt64(3, track.fudgeListenedAt()) |
|
_, err = stmt.Step() |
|
return |
|
} |
|
|
|
type SubmitListens struct { |
|
ListenType string `json:"listen_type"` |
|
Payload []ListenBrainzPayload `json:"payload"` |
|
} |
|
|
|
func validateToken(w http.ResponseWriter, r *http.Request) { |
|
log.Printf("validate token") |
|
r.Header.Set("Content-Type", "application/json") |
|
w.Write([]byte(`{"code": 200, "message": "wiggly donkers", "valid": true, "user": "eta"}`)) |
|
} |
|
|
|
func submitListen(w http.ResponseWriter, r *http.Request) { |
|
var payload SubmitListens |
|
body, err := io.ReadAll(r.Body) |
|
if err != nil { |
|
panic(err) |
|
} |
|
log.Printf("submit listen: %v", string(body)) |
|
err = json.NewDecoder(bytes.NewReader(body)).Decode(&payload) |
|
if err != nil { |
|
panic(err) |
|
} |
|
log.Printf("parsed: %#v\n", payload) |
|
if payload.ListenType == "playing_now" { |
|
currentScrobble = &Scrobble{ |
|
Artist: payload.Payload[0].TrackMetadata.ArtistName, |
|
Name: payload.Payload[0].TrackMetadata.TrackName, |
|
Ts: time.Now(), |
|
} |
|
log.Printf("now playing: %v — %v", currentScrobble.Artist, currentScrobble.Name) |
|
} else if payload.ListenType == "single" || payload.ListenType == "import" { |
|
log.Printf("submission of type %v", payload.ListenType) |
|
for _, item := range payload.Payload { |
|
log.Printf("inserting: %v — %v", item.TrackMetadata.ArtistName, item.TrackMetadata.TrackName) |
|
err := insertTrack(item) |
|
if err != nil { |
|
panic(err) |
|
} |
|
} |
|
} |
|
r.Header.Set("Content-Type", "application/json") |
|
w.Write([]byte(`{"status": "ok"}`)) |
|
} |
|
|
|
func nowPlayingJson(w http.ResponseWriter, r *http.Request) { |
|
log.Printf("now playing") |
|
if currentScrobble == nil { |
|
http.NotFound(w, r) |
|
return |
|
} |
|
if time.Since(currentScrobble.Ts) > 30*time.Minute { |
|
http.NotFound(w, r) |
|
return |
|
} |
|
r.Header.Set("Content-Type", "application/json") |
|
json.NewEncoder(w).Encode(*currentScrobble) |
|
} |
|
|
|
func rootPage(w http.ResponseWriter, r *http.Request) { |
|
log.Printf("root page %v", r.URL.String()) |
|
pageData := make(map[string]interface{}) |
|
var err error |
|
pageData["TotalSongs"], err = countAllScrobbles() |
|
if err != nil { |
|
log.Fatalf("failed to count scrobbles: %v", err) |
|
} |
|
artists, err := getAllArtists() |
|
if err != nil { |
|
log.Fatalf("failed to get artists: %v", err) |
|
} |
|
pageData["Artists"] = artists |
|
pageData["TotalArtists"] = len(artists) |
|
pageData["PreviousTrack"], err = getLastScrobble() |
|
if err != nil { |
|
log.Fatalf("failed to get last scrobble: %v", err) |
|
} |
|
pageData["SomethingPlaying"] = currentScrobble != nil |
|
if currentScrobble != nil { |
|
pageData["CurrentTrack"] = *currentScrobble |
|
} |
|
tmpl.ExecuteTemplate(w, "base", pageData) |
|
} |
|
|
|
func main() { |
|
flag.Parse() |
|
|
|
var err error |
|
dbpool, err = sqlitex.Open(*databasePath, 0, 10) |
|
if err != nil { |
|
log.Fatal(err) |
|
} |
|
|
|
mux := http.NewServeMux() |
|
mux.HandleFunc("/", rootPage) |
|
mux.HandleFunc("/now-playing.json", nowPlayingJson) |
|
mux.HandleFunc("/1/validate-token", validateToken) |
|
mux.HandleFunc("/1/submit-listens", submitListen) |
|
|
|
log.Fatal(http.Serve(autocert.NewListener("etascrobbler.i.eta.st"), mux)) |
|
}
|
|
|