Compare commits
56 Commits
b8150a2f34
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ace5205724 | |||
| f7baf2ee40 | |||
| 0dbbaf38ad | |||
| a9d048a633 | |||
| 56475df1a0 | |||
| 6e0e53eb64 | |||
|
|
24fb1331b4 | ||
| 369aae818c | |||
| d73ae51b95 | |||
| 1b6ff0c283 | |||
| 582d3acbc0 | |||
| 7d70d9ea0f | |||
|
|
181316c343 | ||
| 19ab88268e | |||
| 99185499b1 | |||
| 09ac8b7fb0 | |||
| 7542eaf321 | |||
|
|
a2ffbbdce4 | ||
| 78712188d2 | |||
| 1af3efd7b4 | |||
| a5d0860292 | |||
| 659b68f11d | |||
| d2d325ba46 | |||
| 9979456719 | |||
| 90121b4fd1 | |||
| 78bc1a9974 | |||
| b3c2446add | |||
| 5c5b295961 | |||
| 332460b90d | |||
| 1478410d0c | |||
| fd2e2b0f8a | |||
|
|
1df12b1755 | ||
| c4314456ae | |||
| 7fe4d02721 | |||
| d35e7bffd3 | |||
| a70dc4882b | |||
|
|
8ba7ac55d6 | ||
| 38adb391be | |||
| b9a7a972e2 | |||
| 126b77fa87 | |||
| 4d77999edb | |||
| 32ccdcd5f3 | |||
| a33e724199 | |||
| 349c28e29c | |||
|
|
ad455a36e8 | ||
|
|
c1e1243151 | ||
| e4425bc1e2 | |||
| f7f6b132dc | |||
|
|
ef5b3ec571 | ||
| 1849aae43f | |||
| 1d273248ab | |||
| 22f7f3cf46 | |||
| 9e21d0d5c7 | |||
| 77796e79a4 | |||
|
|
c01ecaa4a6 | ||
| eb06ddc35c |
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
25
.github/workflows/go.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# This workflow will build a golang project
|
||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
|
||||
|
||||
name: Go
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
3
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
imports
|
||||
muzi
|
||||
static/uploads/
|
||||
|
||||
11
README.md
@@ -2,7 +2,7 @@
|
||||
## Self-hosted music listening statistics
|
||||
|
||||
### Requirements
|
||||
- Go 1.25.4+
|
||||
- Go 1.25+
|
||||
- PostgreSQL
|
||||
|
||||
### Roadmap:
|
||||
@@ -12,12 +12,11 @@
|
||||
- Apple Music \[Planned\]
|
||||
|
||||
- WebUI \[In Progress\]
|
||||
- Full listening history with time \[Functional\]
|
||||
- Full listening history with time \[Complete\]
|
||||
- Daily, weekly, monthly, yearly, lifetime presets for listening reports
|
||||
- Ability to specify a certain point in time from one datetime to another to list data
|
||||
- Grid maker (3x3-10x10)
|
||||
- Ability to change artist image
|
||||
- Multi artist scrobbling
|
||||
- Ability to "sync" offline scrobbles (send from a device to the server)
|
||||
- Live scrobbling to the server (Now playing)
|
||||
- Ability to change artist and album images \[Complete\]
|
||||
- Multi artist scrobbling \[Complete\]
|
||||
- Live scrobbling to the server (With Now playing status) \[Complete\]
|
||||
- Batch scrobble editor
|
||||
|
||||
9
config.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[server]
|
||||
address = "0.0.0.0:1234"
|
||||
|
||||
[database]
|
||||
host = "localhost"
|
||||
port = "5432"
|
||||
user = "postgres"
|
||||
password = "postgres"
|
||||
name = "muzi"
|
||||
71
config/config.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Address string
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
Name string
|
||||
}
|
||||
|
||||
var cfg *Config
|
||||
|
||||
func LoadConfig() (*Config, error) {
|
||||
cfg = &Config{
|
||||
Server: ServerConfig{
|
||||
Address: "0.0.0.0:1234",
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Host: "localhost",
|
||||
Port: "5432",
|
||||
User: "postgres",
|
||||
Password: "postgres",
|
||||
Name: "muzi",
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := os.Stat("config.toml"); err == nil {
|
||||
_, err := toml.DecodeFile("config.toml", cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing config.toml: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func Get() *Config {
|
||||
if cfg == nil {
|
||||
var err error
|
||||
cfg, err = LoadConfig()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to load config: %v", err))
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (d *DatabaseConfig) GetDbUrl(withDb bool) string {
|
||||
if withDb {
|
||||
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s",
|
||||
d.User, d.Password, d.Host, d.Port, d.Name)
|
||||
}
|
||||
return fmt.Sprintf("postgres://%s:%s@%s:%s",
|
||||
d.User, d.Password, d.Host, d.Port)
|
||||
}
|
||||
233
db/db.go
@@ -5,57 +5,91 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"muzi/config"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TableExists(name string, conn *pgx.Conn) bool {
|
||||
var exists bool
|
||||
err := conn.QueryRow(
|
||||
context.Background(),
|
||||
`SELECT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND
|
||||
tablename = $1);`,
|
||||
name,
|
||||
).
|
||||
Scan(&exists)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "SELECT EXISTS failed: %v\n", err)
|
||||
return false
|
||||
var Pool *pgxpool.Pool
|
||||
|
||||
func CreateAllTables() error {
|
||||
if err := CreateExtensions(); err != nil {
|
||||
return err
|
||||
}
|
||||
return exists
|
||||
if err := CreateHistoryTable(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := CreateUsersTable(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := CreateSessionsTable(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := CreateSpotifyLastTrackTable(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := CreateArtistsTable(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := CreateAlbumsTable(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := CreateSongsTable(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := AddHistoryEntityColumns(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DbExists() bool {
|
||||
conn, err := pgx.Connect(
|
||||
context.Background(),
|
||||
"postgres://postgres:postgres@localhost:5432/muzi",
|
||||
)
|
||||
func CreateExtensions() error {
|
||||
_, err := Pool.Exec(context.Background(),
|
||||
"CREATE EXTENSION IF NOT EXISTS pg_trgm;")
|
||||
if err != nil {
|
||||
return false
|
||||
fmt.Fprintf(os.Stderr, "Error creating pg_trgm extension: %v\n", err)
|
||||
return err
|
||||
}
|
||||
defer conn.Close(context.Background())
|
||||
return true
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetDbUrl(dbName bool) string {
|
||||
return config.Get().Database.GetDbUrl(dbName)
|
||||
}
|
||||
|
||||
func CreateDB() error {
|
||||
conn, err := pgx.Connect(
|
||||
context.Background(),
|
||||
"postgres://postgres:postgres@localhost:5432",
|
||||
conn, err := pgx.Connect(context.Background(),
|
||||
GetDbUrl(false),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot connect to PostgreSQL: %v\n", err)
|
||||
return err
|
||||
}
|
||||
defer conn.Close(context.Background())
|
||||
|
||||
var exists bool
|
||||
err = conn.QueryRow(context.Background(),
|
||||
"SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = 'muzi')").Scan(&exists)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error checking if database exists: %v\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = conn.Exec(context.Background(), "CREATE DATABASE muzi")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot create muzi database: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Error creating muzi database: %v\n", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateHistoryTable(conn *pgx.Conn) error {
|
||||
_, err := conn.Exec(context.Background(),
|
||||
func CreateHistoryTable() error {
|
||||
_, err := Pool.Exec(context.Background(),
|
||||
`CREATE TABLE IF NOT EXISTS history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
@@ -64,7 +98,7 @@ func CreateHistoryTable(conn *pgx.Conn) error {
|
||||
artist TEXT NOT NULL,
|
||||
album_name TEXT,
|
||||
ms_played INTEGER,
|
||||
platform TEXT DEFAULT 'spotify',
|
||||
platform TEXT,
|
||||
UNIQUE (user_id, song_name, artist, timestamp)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_user_timestamp ON history(user_id, timestamp DESC);
|
||||
@@ -77,19 +111,152 @@ func CreateHistoryTable(conn *pgx.Conn) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateUsersTable(conn *pgx.Conn) error {
|
||||
_, err := conn.Exec(context.Background(),
|
||||
func CreateUsersTable() error {
|
||||
_, err := Pool.Exec(context.Background(),
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
username TEXT NOT NULL,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
bio TEXT DEFAULT 'This profile has no bio.',
|
||||
pfp TEXT DEFAULT '/files/assets/default.png',
|
||||
pfp TEXT DEFAULT '/files/assets/pfps/default.png',
|
||||
allow_duplicate_edits BOOLEAN DEFAULT FALSE,
|
||||
api_key TEXT,
|
||||
api_secret TEXT,
|
||||
spotify_client_id TEXT,
|
||||
spotify_client_secret TEXT,
|
||||
spotify_access_token TEXT,
|
||||
spotify_refresh_token TEXT,
|
||||
spotify_token_expires TIMESTAMPTZ,
|
||||
last_spotify_check TIMESTAMPTZ,
|
||||
pk SERIAL PRIMARY KEY
|
||||
);`)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_api_key ON users(api_key);`)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating users table: %v\n", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateSessionsTable() error {
|
||||
_, err := Pool.Exec(context.Background(),
|
||||
`CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL REFERENCES users(username),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '30 days'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);`)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating sessions table: %v\n", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CleanupExpiredSessions() error {
|
||||
_, err := Pool.Exec(context.Background(),
|
||||
"DELETE FROM sessions WHERE expires_at < NOW();")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error cleaning up sessions: %v\n", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateSpotifyLastTrackTable() error {
|
||||
_, err := Pool.Exec(context.Background(),
|
||||
`CREATE TABLE IF NOT EXISTS spotify_last_track (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(pk) ON DELETE CASCADE,
|
||||
track_id TEXT NOT NULL,
|
||||
song_name TEXT NOT NULL,
|
||||
artist TEXT NOT NULL,
|
||||
album_name TEXT,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
progress_ms INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);`)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating spotify_last_track table: %v\n", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateArtistsTable() error {
|
||||
_, err := Pool.Exec(context.Background(),
|
||||
`CREATE TABLE IF NOT EXISTS artists (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(pk) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
image_url TEXT,
|
||||
bio TEXT,
|
||||
spotify_id TEXT,
|
||||
musicbrainz_id TEXT,
|
||||
UNIQUE (user_id, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_artists_user_name ON artists(user_id, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_artists_user_name_trgm ON artists USING gin(name gin_trgm_ops);`)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating artists table: %v\n", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateAlbumsTable() error {
|
||||
_, err := Pool.Exec(context.Background(),
|
||||
`CREATE TABLE IF NOT EXISTS albums (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(pk) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
artist_id INTEGER REFERENCES artists(id) ON DELETE SET NULL,
|
||||
cover_url TEXT,
|
||||
spotify_id TEXT,
|
||||
musicbrainz_id TEXT,
|
||||
UNIQUE (user_id, title, artist_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_albums_user_title ON albums(user_id, title);
|
||||
CREATE INDEX IF NOT EXISTS idx_albums_user_title_trgm ON albums USING gin(title gin_trgm_ops);`)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating albums table: %v\n", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateSongsTable() error {
|
||||
_, err := Pool.Exec(context.Background(),
|
||||
`CREATE TABLE IF NOT EXISTS songs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(pk) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
artist_id INTEGER REFERENCES artists(id) ON DELETE SET NULL,
|
||||
album_id INTEGER REFERENCES albums(id) ON DELETE SET NULL,
|
||||
duration_ms INTEGER,
|
||||
spotify_id TEXT,
|
||||
musicbrainz_id TEXT,
|
||||
UNIQUE (user_id, title, artist_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_user_title ON songs(user_id, title);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_user_title_trgm ON songs USING gin(title gin_trgm_ops);`)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating songs table: %v\n", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func AddHistoryEntityColumns() error {
|
||||
_, err := Pool.Exec(context.Background(),
|
||||
`ALTER TABLE history ADD COLUMN IF NOT EXISTS artist_id INTEGER REFERENCES artists(id) ON DELETE SET NULL;
|
||||
ALTER TABLE history ADD COLUMN IF NOT EXISTS song_id INTEGER REFERENCES songs(id) ON DELETE SET NULL;
|
||||
ALTER TABLE history ADD COLUMN IF NOT EXISTS artist_ids INTEGER[] DEFAULT '{}';
|
||||
CREATE INDEX IF NOT EXISTS idx_history_artist_id ON history(artist_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_song_id ON history(song_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_artist_ids ON history USING gin(artist_ids);`)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error adding history entity columns: %v\n", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
1003
db/entities.go
Normal file
7
go.mod
@@ -1,17 +1,20 @@
|
||||
module muzi
|
||||
|
||||
go 1.25.4
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/jackc/pgtype v1.14.4
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
golang.org/x/crypto v0.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/jackc/pgio v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
)
|
||||
|
||||
3
go.sum
@@ -1,4 +1,6 @@
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
@@ -66,7 +68,6 @@ github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8
|
||||
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0=
|
||||
github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
|
||||
105
main.go
@@ -2,112 +2,39 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"muzi/config"
|
||||
"muzi/db"
|
||||
"muzi/migrate"
|
||||
"muzi/scrobble"
|
||||
"muzi/web"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func dbCheck() error {
|
||||
if !db.DbExists() {
|
||||
err := db.CreateDB()
|
||||
func check(msg string, err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating muzi DB: %v\n", err)
|
||||
return err
|
||||
fmt.Fprintf(os.Stderr, "Error %s: %v\n", msg, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dirCheck(path string) error {
|
||||
_, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
os.MkdirAll(path, os.ModePerm)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error checking dir: %s: %v\n", path, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
dirImports := filepath.Join(".", "imports")
|
||||
|
||||
dirSpotify := filepath.Join(".", "imports", "spotify")
|
||||
dirSpotifyZip := filepath.Join(".", "imports", "spotify", "zip")
|
||||
dirSpotifyExt := filepath.Join(".", "imports", "spotify", "extracted")
|
||||
|
||||
fmt.Printf("Checking if directory %s exists...\n", dirImports)
|
||||
err := dirCheck(dirImports)
|
||||
_, err := config.LoadConfig()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Printf("Checking if directory %s exists...\n", dirSpotify)
|
||||
err = dirCheck(dirSpotify)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Printf("Checking if directory %s exists...\n", dirSpotifyZip)
|
||||
err = dirCheck(dirSpotifyZip)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Printf("Checking if directory %s exists...\n", dirSpotifyExt)
|
||||
err = dirCheck(dirSpotifyExt)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Println("Checking if muzi database exists...")
|
||||
err = dbCheck()
|
||||
if err != nil {
|
||||
return
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Setting up database tables...")
|
||||
conn, err := pgx.Connect(
|
||||
context.Background(),
|
||||
"postgres://postgres:postgres@localhost:5432/muzi",
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot connect to muzi database: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close(context.Background())
|
||||
check("ensuring muzi DB exists", db.CreateDB())
|
||||
|
||||
err = db.CreateHistoryTable(conn)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating history table: %v\n", err)
|
||||
return
|
||||
}
|
||||
db.Pool, err = pgxpool.New(context.Background(), db.GetDbUrl(true))
|
||||
check("connecting to muzi database", err)
|
||||
defer db.Pool.Close()
|
||||
|
||||
err = db.CreateUsersTable(conn)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating users table: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
username := ""
|
||||
apiKey := ""
|
||||
fmt.Printf("Importing LastFM data for %s\n", username)
|
||||
// TODO:
|
||||
// remove hardcoded userID by creating webUI import pages and getting
|
||||
// userID from login session
|
||||
err = migrate.ImportLastFM(username, apiKey, 1)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = migrate.ImportSpotify(1)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
check("ensuring all tables exist", db.CreateAllTables())
|
||||
check("cleaning expired sessions", db.CleanupExpiredSessions())
|
||||
scrobble.StartSpotifyPoller()
|
||||
web.Start()
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"muzi/db"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
@@ -28,6 +30,15 @@ type pageResult struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type ProgressUpdate struct {
|
||||
CurrentPage int `json:"current_page"`
|
||||
CompletedPages int `json:"completed_pages"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TracksImported int `json:"tracks_imported"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Recenttracks struct {
|
||||
Track []struct {
|
||||
@@ -51,63 +62,19 @@ type Response struct {
|
||||
} `json:"recenttracks"`
|
||||
}
|
||||
|
||||
func ImportLastFM(username string, apiKey string, userId int) error {
|
||||
conn, err := pgx.Connect(
|
||||
context.Background(),
|
||||
"postgres://postgres:postgres@localhost:5432/muzi",
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot connect to muzi database: %v\n", err)
|
||||
return err
|
||||
}
|
||||
defer conn.Close(context.Background())
|
||||
|
||||
totalImported := 0
|
||||
|
||||
resp, err := http.Get(
|
||||
func fetchPage(client *http.Client, page int, lfmUsername, apiKey string, userId int) pageResult {
|
||||
resp, err := client.Get(
|
||||
"https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=" +
|
||||
username + "&api_key=" + apiKey + "&format=json&limit=100",
|
||||
lfmUsername + "&api_key=" + apiKey + "&format=json&limit=100&page=" + strconv.Itoa(page),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting LastFM HTTP response: %v\n", err)
|
||||
return err
|
||||
}
|
||||
var initialData Response
|
||||
json.NewDecoder(resp.Body).Decode(&initialData)
|
||||
totalPages, err := strconv.Atoi(initialData.Recenttracks.Attr.TotalPages)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing total pages: %v\n", err)
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Total pages: %d\n", totalPages)
|
||||
|
||||
trackBatch := make([]LastFMTrack, 0, 1000)
|
||||
|
||||
pageChan := make(chan pageResult, 20)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(10)
|
||||
|
||||
for worker := range 10 {
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
for page := workerID + 1; page <= totalPages; page += 10 {
|
||||
resp, err := http.Get(
|
||||
"https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=" +
|
||||
username + "&api_key=" + apiKey + "&format=json&limit=100&page=" + strconv.Itoa(page),
|
||||
)
|
||||
if err != nil {
|
||||
pageChan <- pageResult{pageNum: page, err: err}
|
||||
continue
|
||||
return pageResult{pageNum: page, err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var data Response
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
resp.Body.Close()
|
||||
pageChan <- pageResult{pageNum: page, err: err}
|
||||
continue
|
||||
return pageResult{pageNum: page, err: err}
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
var pageTracks []LastFMTrack
|
||||
for j := range data.Recenttracks.Track {
|
||||
@@ -126,7 +93,72 @@ func ImportLastFM(username string, apiKey string, userId int) error {
|
||||
Album: data.Recenttracks.Track[j].Album.Text,
|
||||
})
|
||||
}
|
||||
pageChan <- pageResult{pageNum: page, tracks: pageTracks, err: nil}
|
||||
return pageResult{pageNum: page, tracks: pageTracks, err: nil}
|
||||
}
|
||||
|
||||
func ImportLastFM(
|
||||
lfmUsername string,
|
||||
apiKey string,
|
||||
userId int,
|
||||
progressChan chan<- ProgressUpdate,
|
||||
username string,
|
||||
) error {
|
||||
totalImported := 0
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get(
|
||||
"https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=" +
|
||||
lfmUsername + "&api_key=" + apiKey + "&format=json&limit=100",
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting LastFM HTTP response: %v\n", err)
|
||||
if progressChan != nil {
|
||||
progressChan <- ProgressUpdate{Status: "error", Error: err.Error()}
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var initialData Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&initialData)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"Error decoding initial LastFM response: %v\n", err)
|
||||
return err
|
||||
}
|
||||
totalPages, err := strconv.Atoi(initialData.Recenttracks.Attr.TotalPages)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing total pages: %v\n", err)
|
||||
if progressChan != nil {
|
||||
progressChan <- ProgressUpdate{Status: "error", Error: err.Error()}
|
||||
}
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s started a LastFM import job of %d total pages\n", username,
|
||||
totalPages)
|
||||
|
||||
// send initial progress update
|
||||
if progressChan != nil {
|
||||
progressChan <- ProgressUpdate{
|
||||
TotalPages: totalPages,
|
||||
Status: "running",
|
||||
}
|
||||
}
|
||||
|
||||
trackBatch := make([]LastFMTrack, 0, 1000)
|
||||
|
||||
pageChan := make(chan pageResult, 20)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(10)
|
||||
|
||||
for worker := range 10 {
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
for page := workerID + 1; page <= totalPages; page += 10 {
|
||||
pageChan <- fetchPage(client, page, lfmUsername, apiKey, userId)
|
||||
}
|
||||
}(worker)
|
||||
}
|
||||
@@ -137,6 +169,8 @@ func ImportLastFM(username string, apiKey string, userId int) error {
|
||||
}()
|
||||
|
||||
batchSize := 500
|
||||
completedPages := 0
|
||||
var completedMu sync.Mutex
|
||||
|
||||
for result := range pageChan {
|
||||
if result.err != nil {
|
||||
@@ -147,83 +181,120 @@ func ImportLastFM(username string, apiKey string, userId int) error {
|
||||
for len(trackBatch) >= batchSize {
|
||||
batch := trackBatch[:batchSize]
|
||||
trackBatch = trackBatch[batchSize:]
|
||||
err := insertBatch(conn, batch, &totalImported, batchSize)
|
||||
err := insertBatch(batch, &totalImported)
|
||||
if err != nil {
|
||||
// prevent logs being filled by duplicate warnings
|
||||
if !strings.Contains(err.Error(), "duplicate") {
|
||||
fmt.Fprintf(os.Stderr, "Batch insert failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("Processed page %d/%d\n", result.pageNum, totalPages)
|
||||
}
|
||||
// increment completed pages counter
|
||||
completedMu.Lock()
|
||||
completedPages++
|
||||
currentCompleted := completedPages
|
||||
completedMu.Unlock()
|
||||
|
||||
// send progress update after each page
|
||||
if progressChan != nil {
|
||||
progressChan <- ProgressUpdate{
|
||||
CurrentPage: result.pageNum,
|
||||
CompletedPages: currentCompleted,
|
||||
TotalPages: totalPages,
|
||||
TracksImported: totalImported,
|
||||
Status: "running",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(trackBatch) > 0 {
|
||||
err := insertBatch(conn, trackBatch, &totalImported, batchSize)
|
||||
err := insertBatch(trackBatch, &totalImported)
|
||||
if err != nil {
|
||||
// prevent logs being filled by duplicate warnings
|
||||
if !strings.Contains(err.Error(), "duplicate") {
|
||||
fmt.Fprintf(os.Stderr, "Final batch insert failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("%d tracks imported from LastFM for user %s\n", totalImported, username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertBatch(conn *pgx.Conn, tracks []LastFMTrack, totalImported *int, batchSize int) error {
|
||||
tx, err := conn.Begin(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var batchValues []string
|
||||
var batchArgs []any
|
||||
fmt.Printf("User %s imported %d tracks from LastFM account %s\n",
|
||||
username,
|
||||
totalImported,
|
||||
lfmUsername)
|
||||
|
||||
for i, track := range tracks {
|
||||
batchValues = append(batchValues, fmt.Sprintf(
|
||||
"($%d, $%d, $%d, $%d, $%d, $%d, $%d)",
|
||||
len(batchArgs)+1,
|
||||
len(batchArgs)+2,
|
||||
len(batchArgs)+3,
|
||||
len(batchArgs)+4,
|
||||
len(batchArgs)+5,
|
||||
len(batchArgs)+6,
|
||||
len(batchArgs)+7,
|
||||
))
|
||||
// lastfm doesn't store playtime for each track, so set to 0
|
||||
batchArgs = append(
|
||||
batchArgs,
|
||||
track.UserId,
|
||||
track.Timestamp,
|
||||
track.SongName,
|
||||
track.Artist,
|
||||
track.Album,
|
||||
0,
|
||||
"lastfm",
|
||||
)
|
||||
|
||||
if len(batchValues) >= batchSize || i == len(tracks)-1 {
|
||||
result, err := tx.Exec(
|
||||
context.Background(),
|
||||
`INSERT INTO history (user_id, timestamp, song_name, artist, album_name, ms_played, platform) VALUES `+
|
||||
strings.Join(
|
||||
batchValues,
|
||||
", ",
|
||||
)+` ON CONFLICT ON CONSTRAINT history_user_id_song_name_artist_timestamp_key DO NOTHING;`,
|
||||
batchArgs...,
|
||||
)
|
||||
if err != nil {
|
||||
tx.Rollback(context.Background())
|
||||
return err
|
||||
// send completion update
|
||||
if progressChan != nil {
|
||||
progressChan <- ProgressUpdate{
|
||||
CurrentPage: totalPages,
|
||||
TotalPages: totalPages,
|
||||
TracksImported: totalImported,
|
||||
Status: "completed",
|
||||
}
|
||||
rowsAffected := result.RowsAffected()
|
||||
if rowsAffected > 0 {
|
||||
*totalImported += int(rowsAffected)
|
||||
}
|
||||
batchValues = batchValues[:0]
|
||||
batchArgs = batchArgs[:0]
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertBatch(tracks []LastFMTrack, totalImported *int) error {
|
||||
if len(tracks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
artistIdMap, err := resolveLastFMArtistIds(tracks)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error resolving artist IDs: %v\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
rows := make([][]any, 0, len(tracks))
|
||||
for _, t := range tracks {
|
||||
artistNames := parseArtistString(t.Artist)
|
||||
var artistIds []int
|
||||
for _, name := range artistNames {
|
||||
if ids, ok := artistIdMap[name]; ok {
|
||||
artistIds = append(artistIds, ids...)
|
||||
}
|
||||
}
|
||||
|
||||
primaryArtistId := 0
|
||||
if len(artistIds) > 0 {
|
||||
primaryArtistId = artistIds[0]
|
||||
}
|
||||
|
||||
rows = append(rows, []any{
|
||||
t.UserId, t.Timestamp, t.SongName, t.Artist,
|
||||
t.Album, 0, "lastfm", primaryArtistId, artistIds,
|
||||
})
|
||||
}
|
||||
|
||||
copyCount, err := db.Pool.CopyFrom(context.Background(),
|
||||
pgx.Identifier{"history"},
|
||||
[]string{
|
||||
"user_id", "timestamp", "song_name", "artist", "album_name",
|
||||
"ms_played", "platform", "artist_id", "artist_ids",
|
||||
},
|
||||
pgx.CopyFromRows(rows),
|
||||
)
|
||||
*totalImported += int(copyCount)
|
||||
return err
|
||||
}
|
||||
|
||||
func resolveLastFMArtistIds(tracks []LastFMTrack) (map[string][]int, error) {
|
||||
artistIdMap := make(map[string][]int)
|
||||
|
||||
for _, t := range tracks {
|
||||
artistNames := parseArtistString(t.Artist)
|
||||
for _, name := range artistNames {
|
||||
if _, exists := artistIdMap[name]; !exists {
|
||||
artistId, _, err := db.GetOrCreateArtist(t.UserId, name)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating artist %s: %v\n", name, err)
|
||||
continue
|
||||
}
|
||||
artistIdMap[name] = []int{artistId}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return artistIdMap, nil
|
||||
}
|
||||
|
||||
@@ -1,361 +1,417 @@
|
||||
package migrate
|
||||
|
||||
// Spotify import functionality for migrating Spotify listening history
|
||||
// from JSON export files into the database
|
||||
|
||||
// This file handles:
|
||||
// - Parsing Spotify JSON track data
|
||||
// - Batch processing with deduplication (20-second window)
|
||||
// - Efficient bulk inserts using pgx.CopyFrom
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"muzi/db"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
batchSize = 1000
|
||||
minPlayTime = 20000 // 20000 ms = 20 sec
|
||||
timeDiff = 20 * time.Second
|
||||
)
|
||||
|
||||
// Represents a single listening event from Spotify's JSON export format
|
||||
type SpotifyTrack struct {
|
||||
Timestamp string `json:"ts"`
|
||||
Platform string `json:"-"`
|
||||
Timestamp time.Time `json:"ts"`
|
||||
Played int `json:"ms_played"`
|
||||
Country string `json:"-"`
|
||||
IP string `json:"-"`
|
||||
Name string `json:"master_metadata_track_name"`
|
||||
Artist string `json:"master_metadata_album_artist_name"`
|
||||
Album string `json:"master_metadata_album_album_name"`
|
||||
TrackURI string `json:"-"`
|
||||
Episode string `json:"-"`
|
||||
Show string `json:"-"`
|
||||
EpisodeURI string `json:"-"`
|
||||
Audiobook string `json:"-"`
|
||||
AudiobookURI string `json:"-"`
|
||||
AudiobookChapterURI string `json:"-"`
|
||||
AudiobookChapter string `json:"-"`
|
||||
ReasonStart string `json:"-"`
|
||||
ReasonEnd string `json:"-"`
|
||||
Shuffle bool `json:"-"`
|
||||
Skipped bool `json:"-"`
|
||||
Offline bool `json:"-"`
|
||||
OfflineTimestamp int `json:"-"`
|
||||
Incognito bool `json:"-"`
|
||||
}
|
||||
|
||||
type existingTrack struct {
|
||||
// Implements pgx.CopyFromSource for efficient bulk inserts.
|
||||
// Filters out duplicates in-memory before sending to PostgreSQL
|
||||
type trackSource struct {
|
||||
tracks []SpotifyTrack // Full batch of tracks to process
|
||||
tracksToSkip map[string]struct{} // Set of duplicate keys to skip
|
||||
idx int // Current position in tracks slice
|
||||
userId int // User ID to associate with imported tracks
|
||||
artistIdMap map[string][]int // Map of track key to artist IDs
|
||||
}
|
||||
|
||||
// Represents a track already stored in the database, used for duplicate
|
||||
// detection during import
|
||||
type dbTrack struct {
|
||||
Timestamp time.Time
|
||||
SongName string
|
||||
Artist string
|
||||
}
|
||||
|
||||
func getExistingTracks(conn *pgx.Conn, userId int, tracks []SpotifyTrack) (map[string]bool, error) {
|
||||
if len(tracks) == 0 {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
|
||||
// find min/max timestamps in this batch to create time window
|
||||
var minTs, maxTs time.Time
|
||||
for _, t := range tracks {
|
||||
ts, err := time.Parse(time.RFC3339Nano, t.Timestamp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if minTs.IsZero() || ts.Before(minTs) {
|
||||
minTs = ts
|
||||
}
|
||||
if ts.After(maxTs) {
|
||||
maxTs = ts
|
||||
}
|
||||
}
|
||||
|
||||
if minTs.IsZero() {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
|
||||
// query only tracks within [min-20s, max+20s] window using timestamp index
|
||||
rows, err := conn.Query(context.Background(),
|
||||
`SELECT song_name, artist, timestamp
|
||||
FROM history
|
||||
WHERE user_id = $1
|
||||
AND timestamp BETWEEN $2 AND $3`,
|
||||
userId,
|
||||
minTs.Add(-20*time.Second),
|
||||
maxTs.Add(20*time.Second))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
existing := make(map[string]bool)
|
||||
var existingTracks []existingTrack
|
||||
for rows.Next() {
|
||||
var t existingTrack
|
||||
if err := rows.Scan(&t.SongName, &t.Artist, &t.Timestamp); err != nil {
|
||||
continue
|
||||
}
|
||||
existingTracks = append(existingTracks, t)
|
||||
}
|
||||
|
||||
// check each incoming track against existing ones within 20 second window
|
||||
for _, newTrack := range tracks {
|
||||
newTs, err := time.Parse(time.RFC3339Nano, newTrack.Timestamp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, existTrack := range existingTracks {
|
||||
if newTrack.Name == existTrack.SongName && newTrack.Artist == existTrack.Artist {
|
||||
diff := newTs.Sub(existTrack.Timestamp)
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff < 20*time.Second {
|
||||
key := fmt.Sprintf("%s|%s|%s", newTrack.Artist, newTrack.Name, newTrack.Timestamp)
|
||||
existing[key] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func JsonToDB(jsonFile string, userId int) error {
|
||||
conn, err := pgx.Connect(
|
||||
context.Background(),
|
||||
"postgres://postgres:postgres@localhost:5432/muzi",
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot connect to muzi database: %v\n", err)
|
||||
panic(err)
|
||||
}
|
||||
defer conn.Close(context.Background())
|
||||
|
||||
jsonData, err := os.ReadFile(jsonFile)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot read %s: %v\n", jsonFile, err)
|
||||
return err
|
||||
}
|
||||
var tracks []SpotifyTrack
|
||||
err = json.Unmarshal(jsonData, &tracks)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot unmarshal %s: %v\n", jsonFile, err)
|
||||
return err
|
||||
}
|
||||
// Import Spotify listening history into the database.
|
||||
// Processes tracks in batches of 1000 (default), filters out tracks played <
|
||||
// 20 seconds, deduplicates against existing data, and sends progress updates
|
||||
// via progressChan.
|
||||
// The progressChan must not be closed by the caller. The receiver should
|
||||
// stop reading when Status is "completed". This avoids panics from
|
||||
// sending on a closed channel.
|
||||
|
||||
func ImportSpotify(tracks []SpotifyTrack,
|
||||
userId int, progressChan chan ProgressUpdate,
|
||||
) {
|
||||
totalImported := 0
|
||||
batchSize := 1000
|
||||
totalTracks := len(tracks)
|
||||
batchStart := 0
|
||||
totalBatches := (totalTracks + batchSize - 1) / batchSize
|
||||
|
||||
for batchStart := 0; batchStart < len(tracks); batchStart += batchSize {
|
||||
batchEnd := batchStart + batchSize
|
||||
if batchEnd > len(tracks) {
|
||||
batchEnd = len(tracks)
|
||||
}
|
||||
// Send initial progress update
|
||||
sendProgressUpdate(progressChan, 0, 0, totalBatches, totalImported, "running")
|
||||
|
||||
for batchStart < totalTracks {
|
||||
// Cap batchEnd at total track count on final batch to prevent OOB error
|
||||
batchEnd := min(batchStart+batchSize, totalTracks)
|
||||
currentBatch := (batchStart / batchSize) + 1
|
||||
|
||||
var validTracks []SpotifyTrack
|
||||
for i := batchStart; i < batchEnd; i++ {
|
||||
if tracks[i].Played >= 20000 && tracks[i].Name != "" && tracks[i].Artist != "" {
|
||||
if tracks[i].Played >= minPlayTime &&
|
||||
tracks[i].Name != "" &&
|
||||
tracks[i].Artist != "" {
|
||||
validTracks = append(validTracks, tracks[i])
|
||||
}
|
||||
}
|
||||
|
||||
if len(validTracks) == 0 {
|
||||
batchStart += batchSize
|
||||
// Send progress update even for empty batches
|
||||
sendProgressUpdate(
|
||||
progressChan,
|
||||
currentBatch,
|
||||
currentBatch,
|
||||
totalBatches,
|
||||
totalImported,
|
||||
"running",
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
existing, err := getExistingTracks(conn, userId, validTracks)
|
||||
tracksToSkip, err := getDupes(userId, validTracks)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error checking existing tracks: %v\n", err)
|
||||
batchStart += batchSize
|
||||
continue
|
||||
}
|
||||
|
||||
var batchValues []string
|
||||
var batchArgs []any
|
||||
|
||||
for _, t := range validTracks {
|
||||
key := fmt.Sprintf("%s|%s|%s", t.Artist, t.Name, t.Timestamp)
|
||||
if existing[key] {
|
||||
artistIdMap, err := resolveArtistIds(userId, validTracks)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error resolving artist IDs: %v\n", err)
|
||||
batchStart += batchSize
|
||||
continue
|
||||
}
|
||||
|
||||
batchValues = append(batchValues, fmt.Sprintf(
|
||||
"($%d, $%d, $%d, $%d, $%d, $%d, $%d)",
|
||||
len(batchArgs)+1,
|
||||
len(batchArgs)+2,
|
||||
len(batchArgs)+3,
|
||||
len(batchArgs)+4,
|
||||
len(batchArgs)+5,
|
||||
len(batchArgs)+6,
|
||||
len(batchArgs)+7,
|
||||
))
|
||||
batchArgs = append(
|
||||
batchArgs,
|
||||
src := &trackSource{
|
||||
tracks: validTracks,
|
||||
tracksToSkip: tracksToSkip,
|
||||
idx: 0,
|
||||
userId: userId,
|
||||
artistIdMap: artistIdMap,
|
||||
}
|
||||
|
||||
copyCount, err := db.Pool.CopyFrom(
|
||||
context.Background(),
|
||||
pgx.Identifier{"history"},
|
||||
[]string{
|
||||
"user_id",
|
||||
"timestamp",
|
||||
"song_name",
|
||||
"artist",
|
||||
"album_name",
|
||||
"ms_played",
|
||||
"platform",
|
||||
"artist_id",
|
||||
"artist_ids",
|
||||
},
|
||||
src,
|
||||
)
|
||||
// Do not log errors that come from adding duplicate songs
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "duplicate") {
|
||||
fmt.Fprintf(os.Stderr, "Spotify batch insert failed: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
totalImported += int(copyCount)
|
||||
}
|
||||
|
||||
sendProgressUpdate(
|
||||
progressChan,
|
||||
currentBatch,
|
||||
currentBatch,
|
||||
totalBatches,
|
||||
totalImported,
|
||||
"running",
|
||||
)
|
||||
|
||||
batchStart += batchSize
|
||||
}
|
||||
|
||||
sendProgressUpdate(
|
||||
progressChan,
|
||||
totalBatches,
|
||||
totalBatches,
|
||||
totalBatches,
|
||||
totalImported,
|
||||
"completed",
|
||||
)
|
||||
}
|
||||
|
||||
// Sends a progress update to the channel if it's not nil.
|
||||
// To avoid panics from sending on a closed channel, the channel
|
||||
// must never be closed by the receiver. The receiver should stop reading when
|
||||
// Status reads "completed".
|
||||
func sendProgressUpdate(
|
||||
ch chan ProgressUpdate,
|
||||
current, completed, total, imported int,
|
||||
status string,
|
||||
) {
|
||||
if ch != nil {
|
||||
ch <- ProgressUpdate{
|
||||
CurrentPage: current,
|
||||
CompletedPages: completed,
|
||||
TotalPages: total,
|
||||
TracksImported: imported,
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finds tracks that already exist in the database or are duplicates within the
|
||||
// current batch, using a 20-second window to handle minor timestamp variations
|
||||
func getDupes(userId int, tracks []SpotifyTrack) (map[string]struct{}, error) {
|
||||
minTs, maxTs := findTimeRange(tracks)
|
||||
if minTs.IsZero() {
|
||||
return map[string]struct{}{}, nil
|
||||
}
|
||||
|
||||
dbTracks, err := fetchDbTracks(userId, minTs, maxTs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dbIndex := buildDbTrackIndex(dbTracks)
|
||||
duplicates := make(map[string]struct{})
|
||||
seenInBatch := make(map[string]struct{})
|
||||
|
||||
for _, track := range tracks {
|
||||
trackKey := createTrackKey(track)
|
||||
|
||||
// Check in batch
|
||||
if _, seen := seenInBatch[trackKey]; seen {
|
||||
duplicates[trackKey] = struct{}{}
|
||||
continue
|
||||
}
|
||||
seenInBatch[trackKey] = struct{}{}
|
||||
|
||||
// Check in DB
|
||||
lookupKey := fmt.Sprintf("%s|%s", track.Artist, track.Name)
|
||||
if dbTimestamps, found := dbIndex[lookupKey]; found {
|
||||
if isDuplicateWithinWindow(track, dbTimestamps) {
|
||||
duplicates[trackKey] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return duplicates, nil
|
||||
}
|
||||
|
||||
func resolveArtistIds(userId int, tracks []SpotifyTrack) (map[string][]int, error) {
|
||||
artistIdMap := make(map[string][]int)
|
||||
|
||||
for _, track := range tracks {
|
||||
trackKey := createTrackKey(track)
|
||||
artistNames := parseArtistString(track.Artist)
|
||||
|
||||
var artistIds []int
|
||||
for _, name := range artistNames {
|
||||
artistId, _, err := db.GetOrCreateArtist(userId, name)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating artist %s: %v\n", name, err)
|
||||
continue
|
||||
}
|
||||
artistIds = append(artistIds, artistId)
|
||||
}
|
||||
|
||||
artistIdMap[trackKey] = artistIds
|
||||
}
|
||||
|
||||
return artistIdMap, nil
|
||||
}
|
||||
|
||||
func parseArtistString(artist string) []string {
|
||||
if artist == "" {
|
||||
return nil
|
||||
}
|
||||
var artists []string
|
||||
for _, a := range strings.Split(artist, ",") {
|
||||
a = strings.TrimSpace(a)
|
||||
if a != "" {
|
||||
artists = append(artists, a)
|
||||
}
|
||||
}
|
||||
return artists
|
||||
}
|
||||
|
||||
// Get the min/max timestamp range for a batch of tracks
|
||||
func findTimeRange(tracks []SpotifyTrack) (time.Time, time.Time) {
|
||||
var minTs, maxTs time.Time
|
||||
for _, t := range tracks {
|
||||
if minTs.IsZero() || t.Timestamp.Before(minTs) {
|
||||
minTs = t.Timestamp
|
||||
}
|
||||
if t.Timestamp.After(maxTs) {
|
||||
maxTs = t.Timestamp
|
||||
}
|
||||
}
|
||||
return minTs, maxTs
|
||||
}
|
||||
|
||||
// Get all tracks in the database for a user that have the same timestamp
|
||||
// range as the current batch
|
||||
func fetchDbTracks(userId int, minTs, maxTs time.Time) ([]dbTrack, error) {
|
||||
rows, err := db.Pool.Query(context.Background(),
|
||||
`SELECT song_name, artist, timestamp
|
||||
FROM history
|
||||
WHERE user_id = $1
|
||||
AND timestamp BETWEEN $2 AND $3`,
|
||||
userId,
|
||||
// Adjust 20 seconds to find duplicates on edges of batch
|
||||
minTs.Add(-timeDiff),
|
||||
maxTs.Add(timeDiff))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var dbTracks []dbTrack
|
||||
for rows.Next() {
|
||||
var t dbTrack
|
||||
if err := rows.Scan(&t.SongName, &t.Artist, &t.Timestamp); err != nil {
|
||||
continue
|
||||
}
|
||||
dbTracks = append(dbTracks, t)
|
||||
}
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dbTracks, nil
|
||||
}
|
||||
|
||||
// Create a lookup map from Artist|Name to timestamps for efficient duplicate
|
||||
// detection.
|
||||
func buildDbTrackIndex(tracks []dbTrack) map[string][]time.Time {
|
||||
index := make(map[string][]time.Time)
|
||||
for _, t := range tracks {
|
||||
key := t.Artist + "|" + t.SongName
|
||||
index[key] = append(index[key], t.Timestamp)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// Generate a unique identifier for a track using artist, name, and
|
||||
// normalized timestamp.
|
||||
func createTrackKey(track SpotifyTrack) string {
|
||||
ts := track.Timestamp.Format(time.RFC3339Nano)
|
||||
return fmt.Sprintf("%s|%s|%s", track.Artist, track.Name, ts)
|
||||
}
|
||||
|
||||
// Check if a track timestamp falls < 20 seconds of another
|
||||
func isDuplicateWithinWindow(track SpotifyTrack,
|
||||
existingTimestamps []time.Time,
|
||||
) bool {
|
||||
for _, existingTime := range existingTimestamps {
|
||||
diff := track.Timestamp.Sub(existingTime)
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff < timeDiff {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Advances to the next valid track, skipping duplicates and invalid timestamps.
|
||||
// Returns false when all tracks have been processed
|
||||
func (s *trackSource) Next() bool {
|
||||
for s.idx < len(s.tracks) {
|
||||
t := s.tracks[s.idx]
|
||||
key := createTrackKey(t)
|
||||
if _, shouldSkip := s.tracksToSkip[key]; shouldSkip {
|
||||
s.idx++
|
||||
continue
|
||||
}
|
||||
s.idx++
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Returns the current track's data formatted for database insertion.
|
||||
// Must only be called after Next() returns true
|
||||
func (s *trackSource) Values() ([]any, error) {
|
||||
// idx is already incremented in Next(), so use idx-1
|
||||
t := s.tracks[s.idx-1]
|
||||
trackKey := createTrackKey(t)
|
||||
artistIds := s.artistIdMap[trackKey]
|
||||
|
||||
primaryArtistId := 0
|
||||
if len(artistIds) > 0 {
|
||||
primaryArtistId = artistIds[0]
|
||||
}
|
||||
|
||||
return []any{
|
||||
s.userId,
|
||||
t.Timestamp,
|
||||
t.Name,
|
||||
t.Artist,
|
||||
t.Album,
|
||||
t.Played,
|
||||
"spotify",
|
||||
)
|
||||
}
|
||||
primaryArtistId,
|
||||
artistIds,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if len(batchValues) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = conn.Exec(
|
||||
context.Background(),
|
||||
`INSERT INTO history (user_id, timestamp, song_name, artist, album_name, ms_played, platform) VALUES `+
|
||||
strings.Join(
|
||||
batchValues,
|
||||
", ",
|
||||
)+
|
||||
` ON CONFLICT ON CONSTRAINT history_user_id_song_name_artist_timestamp_key DO NOTHING;`,
|
||||
batchArgs...,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Batch insert failed: %v\n", err)
|
||||
} else {
|
||||
totalImported += len(batchValues)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("%d tracks imported from %s\n", totalImported, jsonFile)
|
||||
// Returns any error encountered during iteration.
|
||||
// Currently always returns nil as errors are logged in Next()
|
||||
func (s *trackSource) Err() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func AddDirToDB(path string, userId int) error {
|
||||
dirs, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error while reading path: %s: %v\n", path, err)
|
||||
// Implements custom JSON unmarshaling to parse the timestamp
|
||||
func (s *SpotifyTrack) UnmarshalJSON(data []byte) error {
|
||||
type Alias SpotifyTrack
|
||||
aux := &struct {
|
||||
Timestamp string `json:"ts"`
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(s),
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
subPath := filepath.Join(
|
||||
path,
|
||||
dir.Name(),
|
||||
"Spotify Extended Streaming History",
|
||||
)
|
||||
entries, err := os.ReadDir(subPath)
|
||||
|
||||
ts, err := time.Parse(time.RFC3339Nano, aux.Timestamp)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error while reading path: %s: %v\n", subPath, err)
|
||||
return err
|
||||
}
|
||||
for _, f := range entries {
|
||||
jsonFileName := f.Name()
|
||||
if !strings.Contains(jsonFileName, ".json") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(jsonFileName, "Video") {
|
||||
continue
|
||||
}
|
||||
jsonFilePath := filepath.Join(subPath, jsonFileName)
|
||||
err = JsonToDB(jsonFilePath, userId)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"Error adding json data (%s) to muzi database: %v", jsonFilePath, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ImportSpotify(userId int) error {
|
||||
path := filepath.Join(".", "imports", "spotify", "zip")
|
||||
targetBase := filepath.Join(".", "imports", "spotify", "extracted")
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading path: %s: %v\n", path, err)
|
||||
return err
|
||||
}
|
||||
for _, f := range entries {
|
||||
_, err := zip.OpenReader(filepath.Join(path, f.Name()))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error opening zip: %s: %v\n",
|
||||
filepath.Join(path, f.Name()), err)
|
||||
continue
|
||||
}
|
||||
fileName := f.Name()
|
||||
fileFullPath := filepath.Join(path, fileName)
|
||||
fileBaseName := fileName[:(strings.LastIndex(fileName, "."))]
|
||||
targetDirFullPath := filepath.Join(targetBase, fileBaseName)
|
||||
|
||||
err = Extract(fileFullPath, targetDirFullPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error extracting %s to %s: %v\n",
|
||||
fileFullPath, targetDirFullPath, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
err = AddDirToDB(targetBase, userId)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"Error adding directory of data (%s) to muzi database: %v\n",
|
||||
targetBase, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Extract(path string, target string) error {
|
||||
archive, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error opening zip: %s: %v\n", path, err)
|
||||
return err
|
||||
}
|
||||
defer archive.Close()
|
||||
|
||||
zipDir := filepath.Base(path)
|
||||
zipDir = zipDir[:(strings.LastIndex(zipDir, "."))]
|
||||
|
||||
for _, f := range archive.File {
|
||||
filePath := filepath.Join(target, f.Name)
|
||||
fmt.Println("extracting:", filePath)
|
||||
|
||||
if !strings.HasPrefix(
|
||||
filePath,
|
||||
filepath.Clean(target)+string(os.PathSeparator),
|
||||
) {
|
||||
err = fmt.Errorf("Invalid file path: %s", filePath)
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
return err
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
fmt.Println("Creating Directory", filePath)
|
||||
os.MkdirAll(filePath, os.ModePerm)
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error making directory: %s: %v\n",
|
||||
filepath.Dir(filePath), err)
|
||||
return err
|
||||
}
|
||||
fileToExtract, err := os.OpenFile(
|
||||
filePath,
|
||||
os.O_WRONLY|os.O_CREATE|os.O_TRUNC,
|
||||
f.Mode(),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error opening file: %s: %v\n", filePath, err)
|
||||
return err
|
||||
}
|
||||
extractedFile, err := f.Open()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error opening file: %s: %v\n", f.Name, err)
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(fileToExtract, extractedFile); err != nil {
|
||||
fmt.Fprintf(
|
||||
os.Stderr,
|
||||
"Error while copying file: %s to: %s: %v\n",
|
||||
fileToExtract.Name(),
|
||||
extractedFile,
|
||||
err,
|
||||
)
|
||||
return err
|
||||
}
|
||||
fileToExtract.Close()
|
||||
extractedFile.Close()
|
||||
return fmt.Errorf("parsing timestamp: %w", err)
|
||||
}
|
||||
s.Timestamp = ts
|
||||
return nil
|
||||
}
|
||||
|
||||
363
scrobble/lastfm.go
Normal file
@@ -0,0 +1,363 @@
|
||||
package scrobble
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"muzi/db"
|
||||
)
|
||||
|
||||
type LastFMHandler struct{}
|
||||
|
||||
func NewLastFMHandler() *LastFMHandler {
|
||||
return &LastFMHandler{}
|
||||
}
|
||||
|
||||
func (h *LastFMHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" {
|
||||
if r.URL.Query().Get("hs") == "true" {
|
||||
h.handleHandshake(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
h.respond(w, "failed", 400, "Invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
method := r.PostForm.Get("method")
|
||||
apiKey := r.PostForm.Get("api_key")
|
||||
sk := r.PostForm.Get("s")
|
||||
track := r.PostForm.Get("t")
|
||||
|
||||
if method != "" {
|
||||
switch method {
|
||||
case "auth.gettoken":
|
||||
h.handleGetToken(w, apiKey)
|
||||
case "auth.getsession":
|
||||
h.handleGetSession(w, r)
|
||||
case "track.updateNowPlaying":
|
||||
h.handleNowPlaying(w, r)
|
||||
case "track.scrobble":
|
||||
h.handleScrobble(w, r)
|
||||
default:
|
||||
h.respond(w, "failed", 400, fmt.Sprintf("Invalid method: %s", method))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if sk != "" {
|
||||
if r.PostForm.Get("a[0]") != "" && (r.PostForm.Get("t[0]") != "" || r.PostForm.Get("i[0]") != "") {
|
||||
h.handleScrobble(w, r)
|
||||
return
|
||||
}
|
||||
if track != "" {
|
||||
h.handleNowPlaying(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
h.respond(w, "failed", 400, "Missing required parameters")
|
||||
}
|
||||
|
||||
func (h *LastFMHandler) respond(w http.ResponseWriter, status string, code int, message string) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte(fmt.Sprintf("FAILED %s", message)))
|
||||
}
|
||||
|
||||
func (h *LastFMHandler) respondOK(w http.ResponseWriter, content string) {
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.Write([]byte(content))
|
||||
}
|
||||
|
||||
func (h *LastFMHandler) handleHandshake(w http.ResponseWriter, r *http.Request) {
|
||||
username := r.URL.Query().Get("u")
|
||||
token := r.URL.Query().Get("t")
|
||||
authToken := r.URL.Query().Get("a")
|
||||
|
||||
if username == "" || token == "" || authToken == "" {
|
||||
w.Write([]byte("BADAUTH"))
|
||||
return
|
||||
}
|
||||
|
||||
userId, err := GetUserByUsername(username)
|
||||
if err != nil {
|
||||
w.Write([]byte("BADAUTH"))
|
||||
return
|
||||
}
|
||||
|
||||
sessionKey, err := GenerateSessionKey()
|
||||
if err != nil {
|
||||
w.Write([]byte("FAILED Could not generate session"))
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.Pool.Exec(context.Background(),
|
||||
`UPDATE users SET api_secret = $1 WHERE pk = $2`,
|
||||
sessionKey, userId)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error updating session key: %v\n", err)
|
||||
w.Write([]byte("FAILED Database error"))
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := getBaseURL(r)
|
||||
w.Write([]byte(fmt.Sprintf("OK\n%s\n%s/2.0/\n%s/2.0/\n", sessionKey, baseURL, baseURL)))
|
||||
}
|
||||
|
||||
func (h *LastFMHandler) handleGetToken(w http.ResponseWriter, apiKey string) {
|
||||
userId, _, err := GetUserByAPIKey(apiKey)
|
||||
if err != nil {
|
||||
h.respond(w, "failed", 10, "Invalid API key")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := GenerateSessionKey()
|
||||
if err != nil {
|
||||
h.respond(w, "failed", 16, "Service temporarily unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
h.respondOK(w, fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<lfm status="ok">
|
||||
<token>%s</token>
|
||||
</lfm>`, token))
|
||||
_ = userId
|
||||
}
|
||||
|
||||
func (h *LastFMHandler) handleGetSession(w http.ResponseWriter, r *http.Request) {
|
||||
apiKey := r.FormValue("api_key")
|
||||
|
||||
userId, username, err := GetUserByAPIKey(apiKey)
|
||||
if err != nil {
|
||||
h.respond(w, "failed", 10, "Invalid API key")
|
||||
return
|
||||
}
|
||||
|
||||
sessionKey, err := GenerateSessionKey()
|
||||
if err != nil {
|
||||
h.respond(w, "failed", 16, "Service temporarily unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.Pool.Exec(context.Background(),
|
||||
`UPDATE users SET api_secret = $1 WHERE pk = $2`,
|
||||
sessionKey, userId)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error updating session key: %v\n", err)
|
||||
h.respond(w, "failed", 16, "Service temporarily unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
h.respondOK(w, fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<lfm status="ok">
|
||||
<session>
|
||||
<name>%s</name>
|
||||
<key>%s</key>
|
||||
<subscriber>0</subscriber>
|
||||
</session>
|
||||
</lfm>`, username, sessionKey))
|
||||
}
|
||||
|
||||
func (h *LastFMHandler) handleNowPlaying(w http.ResponseWriter, r *http.Request) {
|
||||
sessionKey := r.PostForm.Get("s")
|
||||
if sessionKey == "" {
|
||||
h.respond(w, "failed", 9, "Invalid session")
|
||||
return
|
||||
}
|
||||
|
||||
userId, _, err := GetUserBySessionKey(sessionKey)
|
||||
if err != nil {
|
||||
h.respond(w, "failed", 9, "Invalid session")
|
||||
return
|
||||
}
|
||||
|
||||
artist := r.PostForm.Get("a")
|
||||
track := r.PostForm.Get("t")
|
||||
album := r.PostForm.Get("b")
|
||||
|
||||
if track == "" {
|
||||
h.respondOK(w, "OK")
|
||||
return
|
||||
}
|
||||
|
||||
duration := r.PostForm.Get("l")
|
||||
msPlayed := 0
|
||||
if duration != "" {
|
||||
if d, err := strconv.Atoi(duration); err == nil {
|
||||
msPlayed = d * 1000
|
||||
}
|
||||
}
|
||||
|
||||
UpdateNowPlaying(NowPlaying{
|
||||
UserId: userId,
|
||||
SongName: track,
|
||||
Artist: artist,
|
||||
Album: album,
|
||||
MsPlayed: msPlayed,
|
||||
Platform: "lastfm_api",
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
|
||||
h.respondOK(w, "OK")
|
||||
}
|
||||
|
||||
func (h *LastFMHandler) handleScrobble(w http.ResponseWriter, r *http.Request) {
|
||||
sessionKey := r.PostForm.Get("s")
|
||||
if sessionKey == "" {
|
||||
h.respond(w, "failed", 9, "Invalid session")
|
||||
return
|
||||
}
|
||||
|
||||
userId, _, err := GetUserBySessionKey(sessionKey)
|
||||
if err != nil {
|
||||
h.respond(w, "failed", 9, "Invalid session")
|
||||
return
|
||||
}
|
||||
|
||||
scrobbles := h.parseScrobbles(r.PostForm, userId)
|
||||
if len(scrobbles) == 0 {
|
||||
h.respond(w, "failed", 1, "No scrobbles to submit")
|
||||
return
|
||||
}
|
||||
|
||||
accepted, ignored := 0, 0
|
||||
for _, scrobble := range scrobbles {
|
||||
err := SaveScrobble(scrobble)
|
||||
if err != nil {
|
||||
if err.Error() == "duplicate scrobble" {
|
||||
ignored++
|
||||
}
|
||||
continue
|
||||
}
|
||||
accepted++
|
||||
}
|
||||
|
||||
ClearNowPlaying(userId)
|
||||
|
||||
h.respondOK(w, fmt.Sprintf("OK\n%d\n%d\n", accepted, ignored))
|
||||
}
|
||||
|
||||
func (h *LastFMHandler) parseScrobbles(form url.Values, userId int) []Scrobble {
|
||||
var scrobbles []Scrobble
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
var artist, track, album, timestampStr string
|
||||
|
||||
if i == 0 {
|
||||
artist = form.Get("a[0]")
|
||||
track = form.Get("t[0]")
|
||||
album = form.Get("b[0]")
|
||||
timestampStr = form.Get("i[0]")
|
||||
} else {
|
||||
artist = form.Get(fmt.Sprintf("a[%d]", i))
|
||||
track = form.Get(fmt.Sprintf("t[%d]", i))
|
||||
album = form.Get(fmt.Sprintf("b[%d]", i))
|
||||
timestampStr = form.Get(fmt.Sprintf("i[%d]", i))
|
||||
}
|
||||
|
||||
if artist == "" || track == "" || timestampStr == "" {
|
||||
break
|
||||
}
|
||||
|
||||
ts, err := strconv.ParseInt(timestampStr, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
duration := form.Get(fmt.Sprintf("l[%d]", i))
|
||||
msPlayed := 0
|
||||
if duration != "" {
|
||||
if d, err := strconv.Atoi(duration); err == nil {
|
||||
msPlayed = d * 1000
|
||||
}
|
||||
}
|
||||
|
||||
scrobbles = append(scrobbles, Scrobble{
|
||||
UserId: userId,
|
||||
Timestamp: time.Unix(ts, 0).UTC(),
|
||||
SongName: track,
|
||||
Artist: artist,
|
||||
Album: album,
|
||||
MsPlayed: msPlayed,
|
||||
Platform: "lastfm_api",
|
||||
})
|
||||
}
|
||||
|
||||
return scrobbles
|
||||
}
|
||||
|
||||
func SignRequest(params map[string]string, secret string) string {
|
||||
var keys []string
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
for i := 0; i < len(keys)-1; i++ {
|
||||
for j := i + 1; j < len(keys); j++ {
|
||||
if keys[i] > keys[j] {
|
||||
keys[i], keys[j] = keys[j], keys[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var str string
|
||||
for _, k := range keys {
|
||||
str += k + params[k]
|
||||
}
|
||||
str += secret
|
||||
|
||||
hash := md5.Sum([]byte(str))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func SignAPIRequest(params map[string]string, secret string) string {
|
||||
var pairs []string
|
||||
for k, v := range params {
|
||||
pairs = append(pairs, k+"="+url.QueryEscape(v))
|
||||
}
|
||||
signature := SignRequest(map[string]string{"api_key": params["api_key"], "method": params["method"]}, secret)
|
||||
return signature
|
||||
}
|
||||
|
||||
func FetchURL(client *http.Client, endpoint, method string, params map[string]string) (string, error) {
|
||||
data := url.Values{}
|
||||
for k, v := range params {
|
||||
data.Set(k, v)
|
||||
}
|
||||
req, err := http.NewRequest(method, endpoint, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
195
scrobble/listenbrainz.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package scrobble
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ListenbrainzHandler struct{}
|
||||
|
||||
func NewListenbrainzHandler() *ListenbrainzHandler {
|
||||
return &ListenbrainzHandler{}
|
||||
}
|
||||
|
||||
type SubmitListensRequest struct {
|
||||
ListenType string `json:"listen_type"`
|
||||
Payload []ListenPayload `json:"payload"`
|
||||
}
|
||||
|
||||
type ListenPayload struct {
|
||||
ListenedAt int64 `json:"listened_at"`
|
||||
TrackMetadata TrackMetadata `json:"track_metadata"`
|
||||
}
|
||||
|
||||
type TrackMetadata struct {
|
||||
ArtistName string `json:"artist_name"`
|
||||
TrackName string `json:"track_name"`
|
||||
ReleaseName string `json:"release_name"`
|
||||
AdditionalInfo AdditionalInfo `json:"additional_info"`
|
||||
}
|
||||
|
||||
type AdditionalInfo struct {
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
func (h *ListenbrainzHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey := r.Header.Get("Authorization")
|
||||
if apiKey == "" {
|
||||
apiKey = r.URL.Query().Get("token")
|
||||
}
|
||||
|
||||
if apiKey == "" {
|
||||
h.respondError(w, "No authorization token provided", 401)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey = stripBearer(apiKey)
|
||||
|
||||
userId, _, err := GetUserByAPIKey(apiKey)
|
||||
if err != nil {
|
||||
h.respondError(w, "Invalid authorization token", 401)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
h.respondError(w, "Invalid request body", 400)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var req SubmitListensRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
h.respondError(w, "Invalid JSON", 400)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.ListenType {
|
||||
case "single":
|
||||
h.handleScrobbles(w, userId, req.Payload)
|
||||
case "playing_now":
|
||||
h.handleNowPlaying(w, userId, req.Payload)
|
||||
case "import":
|
||||
h.handleScrobbles(w, userId, req.Payload)
|
||||
default:
|
||||
h.respondError(w, "Invalid listen_type", 400)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ListenbrainzHandler) respondError(w http.ResponseWriter, message string, code int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "error",
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ListenbrainzHandler) respondOK(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ListenbrainzHandler) handleScrobbles(w http.ResponseWriter, userId int, payload []ListenPayload) {
|
||||
if len(payload) == 0 {
|
||||
h.respondError(w, "No listens provided", 400)
|
||||
return
|
||||
}
|
||||
|
||||
scrobbles := make([]Scrobble, 0, len(payload))
|
||||
for _, p := range payload {
|
||||
duration := 0
|
||||
if p.TrackMetadata.AdditionalInfo.Duration > 0 {
|
||||
duration = p.TrackMetadata.AdditionalInfo.Duration * 1000
|
||||
}
|
||||
|
||||
scrobbles = append(scrobbles, Scrobble{
|
||||
UserId: userId,
|
||||
Timestamp: time.Unix(p.ListenedAt, 0).UTC(),
|
||||
SongName: p.TrackMetadata.TrackName,
|
||||
Artist: p.TrackMetadata.ArtistName,
|
||||
Album: p.TrackMetadata.ReleaseName,
|
||||
MsPlayed: duration,
|
||||
Platform: "listenbrainz",
|
||||
})
|
||||
}
|
||||
|
||||
accepted, ignored, err := SaveScrobbles(scrobbles)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error saving scrobbles: %v\n", err)
|
||||
h.respondError(w, "Error saving scrobbles", 500)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "ok",
|
||||
"accepted": accepted,
|
||||
"ignored": ignored,
|
||||
"mbids": []string{},
|
||||
"submit_token": "",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ListenbrainzHandler) handleNowPlaying(w http.ResponseWriter, userId int, payload []ListenPayload) {
|
||||
if len(payload) == 0 {
|
||||
h.respondError(w, "No payload provided", 400)
|
||||
return
|
||||
}
|
||||
|
||||
p := payload[0]
|
||||
duration := 0
|
||||
if p.TrackMetadata.AdditionalInfo.Duration > 0 {
|
||||
duration = p.TrackMetadata.AdditionalInfo.Duration * 1000
|
||||
}
|
||||
|
||||
UpdateNowPlaying(NowPlaying{
|
||||
UserId: userId,
|
||||
SongName: p.TrackMetadata.TrackName,
|
||||
Artist: p.TrackMetadata.ArtistName,
|
||||
Album: p.TrackMetadata.ReleaseName,
|
||||
MsPlayed: duration,
|
||||
Platform: "listenbrainz",
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
|
||||
h.respondOK(w)
|
||||
}
|
||||
|
||||
func stripBearer(token string) string {
|
||||
if len(token) > 7 && strings.HasPrefix(token, "Bearer ") {
|
||||
return token[7:]
|
||||
}
|
||||
if len(token) > 6 && strings.HasPrefix(token, "Token ") {
|
||||
return token[6:]
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func ParseTimestamp(ts interface{}) (time.Time, error) {
|
||||
switch v := ts.(type) {
|
||||
case float64:
|
||||
return time.Unix(int64(v), 0).UTC(), nil
|
||||
case string:
|
||||
i, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return time.Unix(i, 0).UTC(), nil
|
||||
default:
|
||||
return time.Time{}, fmt.Errorf("unknown timestamp type")
|
||||
}
|
||||
}
|
||||
417
scrobble/scrobble.go
Normal file
@@ -0,0 +1,417 @@
|
||||
package scrobble
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"muzi/db"
|
||||
|
||||
"github.com/jackc/pgtype"
|
||||
)
|
||||
|
||||
const DuplicateToleranceSeconds = 20
|
||||
|
||||
type Scrobble struct {
|
||||
UserId int
|
||||
Timestamp time.Time
|
||||
SongName string
|
||||
Artist string
|
||||
Album string
|
||||
MsPlayed int
|
||||
Platform string
|
||||
Source string
|
||||
}
|
||||
|
||||
type NowPlaying struct {
|
||||
UserId int
|
||||
SongName string
|
||||
Artist string
|
||||
Album string
|
||||
MsPlayed int
|
||||
Platform string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
var CurrentNowPlaying = make(map[int]map[string]NowPlaying)
|
||||
|
||||
func GenerateAPIKey() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func GenerateAPISecret() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func GenerateSessionKey() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func GetUserByAPIKey(apiKey string) (int, string, error) {
|
||||
if apiKey == "" {
|
||||
return 0, "", fmt.Errorf("empty API key")
|
||||
}
|
||||
|
||||
var userId int
|
||||
var username string
|
||||
err := db.Pool.QueryRow(context.Background(),
|
||||
"SELECT pk, username FROM users WHERE api_key = $1", apiKey).Scan(&userId, &username)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
return userId, username, nil
|
||||
}
|
||||
|
||||
func GetUserByUsername(username string) (int, error) {
|
||||
if username == "" {
|
||||
return 0, fmt.Errorf("empty username")
|
||||
}
|
||||
|
||||
var userId int
|
||||
err := db.Pool.QueryRow(context.Background(),
|
||||
"SELECT pk FROM users WHERE username = $1", username).Scan(&userId)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return userId, nil
|
||||
}
|
||||
|
||||
func GetUserBySessionKey(sessionKey string) (int, string, error) {
|
||||
if sessionKey == "" {
|
||||
return 0, "", fmt.Errorf("empty session key")
|
||||
}
|
||||
|
||||
var userId int
|
||||
var username string
|
||||
err := db.Pool.QueryRow(context.Background(),
|
||||
"SELECT pk, username FROM users WHERE api_secret = $1", sessionKey).Scan(&userId, &username)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
return userId, username, nil
|
||||
}
|
||||
|
||||
func SaveScrobble(scrobble Scrobble) error {
|
||||
exists, err := checkDuplicate(scrobble.UserId, scrobble.Artist, scrobble.SongName, scrobble.Timestamp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return fmt.Errorf("duplicate scrobble")
|
||||
}
|
||||
|
||||
artistNames := parseArtistString(scrobble.Artist)
|
||||
artistIds, err := getOrCreateArtists(scrobble.UserId, artistNames)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
primaryArtistId := 0
|
||||
if len(artistIds) > 0 {
|
||||
primaryArtistId = artistIds[0]
|
||||
}
|
||||
|
||||
var albumId int
|
||||
if scrobble.Album != "" {
|
||||
albumId, _, err = db.GetOrCreateAlbum(scrobble.UserId, scrobble.Album, primaryArtistId)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting/creating album: %v\n", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
songId, _, err := db.GetOrCreateSong(scrobble.UserId, scrobble.SongName, primaryArtistId, albumId)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting/creating song: %v\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Pool.Exec(context.Background(),
|
||||
`INSERT INTO history (user_id, timestamp, song_name, artist, album_name, ms_played, platform, artist_id, song_id, artist_ids)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (user_id, song_name, artist, timestamp) DO NOTHING`,
|
||||
scrobble.UserId, scrobble.Timestamp, scrobble.SongName, scrobble.Artist,
|
||||
scrobble.Album, scrobble.MsPlayed, scrobble.Platform, primaryArtistId, songId, artistIds)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error saving scrobble: %v\n", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseArtistString(artist string) []string {
|
||||
if artist == "" {
|
||||
return nil
|
||||
}
|
||||
var artists []string
|
||||
for _, a := range strings.Split(artist, ",") {
|
||||
a = strings.TrimSpace(a)
|
||||
if a != "" {
|
||||
artists = append(artists, a)
|
||||
}
|
||||
}
|
||||
return artists
|
||||
}
|
||||
|
||||
func getOrCreateArtists(userId int, artistNames []string) ([]int, error) {
|
||||
var artistIds []int
|
||||
for _, name := range artistNames {
|
||||
id, _, err := db.GetOrCreateArtist(userId, name)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting/creating artist: %v\n", err)
|
||||
return nil, err
|
||||
}
|
||||
artistIds = append(artistIds, id)
|
||||
}
|
||||
return artistIds, nil
|
||||
}
|
||||
|
||||
func SaveScrobbles(scrobbles []Scrobble) (int, int, error) {
|
||||
if len(scrobbles) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
accepted := 0
|
||||
ignored := 0
|
||||
|
||||
batchSize := 100
|
||||
for i := 0; i < len(scrobbles); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(scrobbles) {
|
||||
end = len(scrobbles)
|
||||
}
|
||||
|
||||
for _, scrobble := range scrobbles[i:end] {
|
||||
err := SaveScrobble(scrobble)
|
||||
if err != nil {
|
||||
if err.Error() == "duplicate scrobble" {
|
||||
ignored++
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error saving scrobble: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
accepted++
|
||||
}
|
||||
}
|
||||
|
||||
return accepted, ignored, nil
|
||||
}
|
||||
|
||||
func checkDuplicate(userId int, artist, songName string, timestamp time.Time) (bool, error) {
|
||||
var exists bool
|
||||
err := db.Pool.QueryRow(context.Background(),
|
||||
`SELECT EXISTS(
|
||||
SELECT 1 FROM history
|
||||
WHERE user_id = $1
|
||||
AND artist = $2
|
||||
AND song_name = $3
|
||||
AND ABS(EXTRACT(EPOCH FROM (timestamp - $4))) < $5
|
||||
)`,
|
||||
userId, artist, songName, timestamp, DuplicateToleranceSeconds).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func UpdateNowPlaying(np NowPlaying) {
|
||||
if CurrentNowPlaying[np.UserId] == nil {
|
||||
CurrentNowPlaying[np.UserId] = make(map[string]NowPlaying)
|
||||
}
|
||||
CurrentNowPlaying[np.UserId][np.Platform] = np
|
||||
}
|
||||
|
||||
func GetNowPlaying(userId int) (NowPlaying, bool) {
|
||||
platforms := CurrentNowPlaying[userId]
|
||||
if platforms == nil {
|
||||
return NowPlaying{}, false
|
||||
}
|
||||
np, ok := platforms["lastfm_api"]
|
||||
if ok && np.SongName != "" {
|
||||
return np, true
|
||||
}
|
||||
np, ok = platforms["spotify"]
|
||||
if ok && np.SongName != "" {
|
||||
return np, true
|
||||
}
|
||||
np, ok = platforms["listenbrainz"]
|
||||
if ok && np.SongName != "" {
|
||||
return np, true
|
||||
}
|
||||
return NowPlaying{}, false
|
||||
}
|
||||
|
||||
func ClearNowPlaying(userId int) {
|
||||
delete(CurrentNowPlaying, userId)
|
||||
}
|
||||
|
||||
func ClearNowPlayingPlatform(userId int, platform string) {
|
||||
if CurrentNowPlaying[userId] != nil {
|
||||
delete(CurrentNowPlaying[userId], platform)
|
||||
}
|
||||
}
|
||||
|
||||
func GetUserSpotifyCredentials(userId int) (clientId, clientSecret, accessToken, refreshToken string, expiresAt time.Time, err error) {
|
||||
var clientIdPg, clientSecretPg, accessTokenPg, refreshTokenPg pgtype.Text
|
||||
var expiresAtPg pgtype.Timestamptz
|
||||
err = db.Pool.QueryRow(context.Background(),
|
||||
`SELECT spotify_client_id, spotify_client_secret, spotify_access_token,
|
||||
spotify_refresh_token, spotify_token_expires
|
||||
FROM users WHERE pk = $1`,
|
||||
userId).Scan(&clientIdPg, &clientSecretPg, &accessTokenPg, &refreshTokenPg, &expiresAtPg)
|
||||
if err != nil {
|
||||
return "", "", "", "", time.Time{}, err
|
||||
}
|
||||
|
||||
if clientIdPg.Status == pgtype.Present {
|
||||
clientId = clientIdPg.String
|
||||
}
|
||||
if clientSecretPg.Status == pgtype.Present {
|
||||
clientSecret = clientSecretPg.String
|
||||
}
|
||||
if accessTokenPg.Status == pgtype.Present {
|
||||
accessToken = accessTokenPg.String
|
||||
}
|
||||
if refreshTokenPg.Status == pgtype.Present {
|
||||
refreshToken = refreshTokenPg.String
|
||||
}
|
||||
if expiresAtPg.Status == pgtype.Present {
|
||||
expiresAt = expiresAtPg.Time
|
||||
}
|
||||
|
||||
return clientId, clientSecret, accessToken, refreshToken, expiresAt, nil
|
||||
}
|
||||
|
||||
func UpdateUserSpotifyTokens(userId int, accessToken, refreshToken string, expiresIn int) error {
|
||||
expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
_, err := db.Pool.Exec(context.Background(),
|
||||
`UPDATE users SET
|
||||
spotify_access_token = $1,
|
||||
spotify_refresh_token = $2,
|
||||
spotify_token_expires = $3
|
||||
WHERE pk = $4`,
|
||||
accessToken, refreshToken, expiresAt, userId)
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateUserSpotifyCheck(userId int) error {
|
||||
_, err := db.Pool.Exec(context.Background(),
|
||||
`UPDATE users SET last_spotify_check = $1 WHERE pk = $2`,
|
||||
time.Now(), userId)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetUsersWithSpotify() ([]int, error) {
|
||||
rows, err := db.Pool.Query(context.Background(),
|
||||
`SELECT pk FROM users WHERE spotify_client_id IS NOT NULL AND spotify_client_secret IS NOT NULL`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var userIds []int
|
||||
for rows.Next() {
|
||||
var userId int
|
||||
if err := rows.Scan(&userId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userIds = append(userIds, userId)
|
||||
}
|
||||
return userIds, nil
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Pk int
|
||||
Username string
|
||||
Bio string
|
||||
Pfp string
|
||||
AllowDuplicateEdits bool
|
||||
ApiKey *string
|
||||
ApiSecret *string
|
||||
SpotifyClientId *string
|
||||
SpotifyClientSecret *string
|
||||
}
|
||||
|
||||
func GetUserById(userId int) (User, error) {
|
||||
var user User
|
||||
var apiKey, apiSecret, spotifyClientId, spotifyClientSecret pgtype.Text
|
||||
err := db.Pool.QueryRow(context.Background(),
|
||||
`SELECT pk, username, bio, pfp, allow_duplicate_edits, api_key, api_secret,
|
||||
spotify_client_id, spotify_client_secret
|
||||
FROM users WHERE pk = $1`,
|
||||
userId).Scan(&user.Pk, &user.Username, &user.Bio, &user.Pfp,
|
||||
&user.AllowDuplicateEdits, &apiKey, &apiSecret, &spotifyClientId, &spotifyClientSecret)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
if apiKey.Status == pgtype.Present {
|
||||
user.ApiKey = &apiKey.String
|
||||
}
|
||||
if apiSecret.Status == pgtype.Present {
|
||||
user.ApiSecret = &apiSecret.String
|
||||
}
|
||||
if spotifyClientId.Status == pgtype.Present {
|
||||
user.SpotifyClientId = &spotifyClientId.String
|
||||
}
|
||||
if spotifyClientSecret.Status == pgtype.Present {
|
||||
user.SpotifyClientSecret = &spotifyClientSecret.String
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func UpdateUserAPIKey(userId int, apiKey, apiSecret string) error {
|
||||
_, err := db.Pool.Exec(context.Background(),
|
||||
`UPDATE users SET api_key = $1, api_secret = $2 WHERE pk = $3`,
|
||||
apiKey, apiSecret, userId)
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateUserSpotifyCredentials(userId int, clientId, clientSecret string) error {
|
||||
_, err := db.Pool.Exec(context.Background(),
|
||||
`UPDATE users SET spotify_client_id = $1, spotify_client_secret = $2 WHERE pk = $3`,
|
||||
clientId, clientSecret, userId)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteUserSpotifyCredentials(userId int) error {
|
||||
_, err := db.Pool.Exec(context.Background(),
|
||||
`UPDATE users SET
|
||||
spotify_client_id = NULL,
|
||||
spotify_client_secret = NULL,
|
||||
spotify_access_token = NULL,
|
||||
spotify_refresh_token = NULL,
|
||||
spotify_token_expires = NULL
|
||||
WHERE pk = $1`,
|
||||
userId)
|
||||
return err
|
||||
}
|
||||
|
||||
func (u *User) IsSpotifyConnected() bool {
|
||||
_, _, accessToken, _, expiresAt, err := GetUserSpotifyCredentials(u.Pk)
|
||||
if err != nil || accessToken == "" {
|
||||
return false
|
||||
}
|
||||
return time.Now().Before(expiresAt)
|
||||
}
|
||||
506
scrobble/spotify.go
Normal file
@@ -0,0 +1,506 @@
|
||||
package scrobble
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"muzi/db"
|
||||
)
|
||||
|
||||
const SpotifyTokenURL = "https://accounts.spotify.com/api/token"
|
||||
const SpotifyAuthURL = "https://accounts.spotify.com/authorize"
|
||||
const SpotifyAPIURL = "https://api.spotify.com/v1"
|
||||
|
||||
var (
|
||||
spotifyClient = &http.Client{Timeout: 30 * time.Second}
|
||||
spotifyMu sync.Mutex
|
||||
)
|
||||
|
||||
type SpotifyHandler struct{}
|
||||
|
||||
func NewSpotifyHandler() *SpotifyHandler {
|
||||
return &SpotifyHandler{}
|
||||
}
|
||||
|
||||
type SpotifyTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type SpotifyCurrentlyPlaying struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
ProgressMs int `json:"progress_ms"`
|
||||
Item SpotifyTrack `json:"item"`
|
||||
CurrentlyPlayingType string `json:"currently_playing_type"`
|
||||
IsPlaying bool `json:"is_playing"`
|
||||
}
|
||||
|
||||
type SpotifyTrack struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
Artists []SpotifyArtist `json:"artists"`
|
||||
Album SpotifyAlbum `json:"album"`
|
||||
}
|
||||
|
||||
type SpotifyArtist struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type SpotifyAlbum struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type SpotifyRecentPlays struct {
|
||||
Items []SpotifyPlayItem `json:"items"`
|
||||
Cursors SpotifyCursors `json:"cursors"`
|
||||
}
|
||||
|
||||
type SpotifyPlayItem struct {
|
||||
Track SpotifyTrack `json:"track"`
|
||||
PlayedAt string `json:"played_at"`
|
||||
}
|
||||
|
||||
type SpotifyCursors struct {
|
||||
After string `json:"after"`
|
||||
Before string `json:"before"`
|
||||
}
|
||||
|
||||
func (h *SpotifyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
|
||||
if path == "/scrobble/spotify/authorize" {
|
||||
h.handleAuthorize(w, r)
|
||||
} else if path == "/scrobble/spotify/callback" {
|
||||
h.handleCallback(w, r)
|
||||
} else {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SpotifyHandler) handleAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
userId := r.URL.Query().Get("user_id")
|
||||
if userId == "" {
|
||||
http.Error(w, "Missing user_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
clientId, _, _, _, _, err := GetUserSpotifyCredentials(userIdToInt(userId))
|
||||
fmt.Fprintf(os.Stderr, "handleAuthorize: userId=%s, clientId='%s', err=%v\n", userId, clientId, err)
|
||||
if err != nil || clientId == "" {
|
||||
http.Error(w, "Spotify credentials not configured", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := getBaseURL(r)
|
||||
redirectURI := baseURL + "/scrobble/spotify/callback"
|
||||
|
||||
scope := "user-read-currently-playing user-read-recently-played"
|
||||
authURL := fmt.Sprintf("%s?client_id=%s&response_type=code&redirect_uri=%s&scope=%s&state=%s",
|
||||
SpotifyAuthURL, url.QueryEscape(clientId), url.QueryEscape(redirectURI), url.QueryEscape(scope), userId)
|
||||
|
||||
http.Redirect(w, r, authURL, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *SpotifyHandler) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
userId := userIdToInt(state)
|
||||
|
||||
if code == "" || state == "" {
|
||||
http.Error(w, "Missing parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
clientId, clientSecret, _, _, _, err := GetUserSpotifyCredentials(userId)
|
||||
if err != nil || clientId == "" {
|
||||
http.Error(w, "Spotify credentials not configured", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := getBaseURL(r)
|
||||
redirectURI := baseURL + "/scrobble/spotify/callback"
|
||||
|
||||
token, err := exchangeCodeForToken(clientId, clientSecret, code, redirectURI)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error exchanging code for token: %v\n", err)
|
||||
http.Error(w, "Failed to authenticate", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = UpdateUserSpotifyTokens(userId, token.AccessToken, token.RefreshToken, token.ExpiresIn)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error saving Spotify tokens: %v\n", err)
|
||||
http.Error(w, "Failed to save credentials", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<html><body><h1>Spotify connected successfully!</h1><p>You can close this window.</p><script>setTimeout(() => window.close(), 2000);</script></body></html>`)
|
||||
}
|
||||
|
||||
func exchangeCodeForToken(clientId, clientSecret, code, redirectURI string) (*SpotifyTokenResponse, error) {
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "authorization_code")
|
||||
data.Set("code", code)
|
||||
data.Set("redirect_uri", redirectURI)
|
||||
data.Set("client_id", clientId)
|
||||
data.Set("client_secret", clientSecret)
|
||||
|
||||
req, err := http.NewRequest("POST", SpotifyTokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := spotifyClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("Spotify token exchange failed: %s", string(body))
|
||||
}
|
||||
|
||||
var token SpotifyTokenResponse
|
||||
if err := json.Unmarshal(body, &token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
func refreshSpotifyToken(clientId, clientSecret, refreshToken string) (*SpotifyTokenResponse, error) {
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "refresh_token")
|
||||
data.Set("refresh_token", refreshToken)
|
||||
data.Set("client_id", clientId)
|
||||
data.Set("client_secret", clientSecret)
|
||||
|
||||
req, err := http.NewRequest("POST", SpotifyTokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := spotifyClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("Spotify token refresh failed: %s", string(body))
|
||||
}
|
||||
|
||||
var token SpotifyTokenResponse
|
||||
if err := json.Unmarshal(body, &token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
func StartSpotifyPoller() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
spotifyMu.Lock()
|
||||
users, err := GetUsersWithSpotify()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting users with Spotify: %v\n", err)
|
||||
spotifyMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
for _, userId := range users {
|
||||
err := pollSpotify(userId)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error polling Spotify for user %d: %v\n", userId, err)
|
||||
}
|
||||
}
|
||||
spotifyMu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func pollSpotify(userId int) error {
|
||||
clientId, clientSecret, accessToken, refreshToken, expiresAt, err := GetUserSpotifyCredentials(userId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if accessToken == "" {
|
||||
return fmt.Errorf("no access token")
|
||||
}
|
||||
|
||||
if time.Now().After(expiresAt.Add(-60 * time.Second)) {
|
||||
token, err := refreshSpotifyToken(clientId, clientSecret, refreshToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
accessToken = token.AccessToken
|
||||
if token.RefreshToken != "" {
|
||||
refreshToken = token.RefreshToken
|
||||
}
|
||||
UpdateUserSpotifyTokens(userId, accessToken, refreshToken, token.ExpiresIn)
|
||||
}
|
||||
|
||||
err = checkCurrentlyPlaying(userId, accessToken)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error checking currently playing: %v\n", err)
|
||||
}
|
||||
|
||||
err = checkRecentPlays(userId, accessToken)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error checking recent plays: %v\n", err)
|
||||
}
|
||||
|
||||
UpdateUserSpotifyCheck(userId)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkCurrentlyPlaying(userId int, accessToken string) error {
|
||||
req, err := http.NewRequest("GET", SpotifyAPIURL+"/me/player/currently-playing", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := spotifyClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 204 {
|
||||
ClearNowPlayingPlatform(userId, "spotify")
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("currently playing returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var playing SpotifyCurrentlyPlaying
|
||||
if err := json.NewDecoder(resp.Body).Decode(&playing); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !playing.IsPlaying || playing.Item.Name == "" {
|
||||
ClearNowPlayingPlatform(userId, "spotify")
|
||||
return nil
|
||||
}
|
||||
|
||||
artistName := ""
|
||||
if len(playing.Item.Artists) > 0 {
|
||||
artistName = playing.Item.Artists[0].Name
|
||||
}
|
||||
|
||||
checkAndScrobbleHalfway(userId, &playing.Item, playing.ProgressMs)
|
||||
|
||||
UpdateNowPlaying(NowPlaying{
|
||||
UserId: userId,
|
||||
SongName: playing.Item.Name,
|
||||
Artist: artistName,
|
||||
Album: playing.Item.Album.Name,
|
||||
MsPlayed: playing.Item.DurationMs,
|
||||
Platform: "spotify",
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkRecentPlays(userId int, accessToken string) error {
|
||||
req, err := http.NewRequest("GET", SpotifyAPIURL+"/me/player/recently-played?limit=50", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := spotifyClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("recently played returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var recent SpotifyRecentPlays
|
||||
if err := json.NewDecoder(resp.Body).Decode(&recent); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(recent.Items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
scrobbles := make([]Scrobble, 0, len(recent.Items))
|
||||
for _, item := range recent.Items {
|
||||
artistName := ""
|
||||
if len(item.Track.Artists) > 0 {
|
||||
artistName = item.Track.Artists[0].Name
|
||||
}
|
||||
|
||||
ts, err := time.Parse(time.RFC3339, item.PlayedAt)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, " -> failed to parse timestamp %s: %v\n", item.PlayedAt, err)
|
||||
continue
|
||||
}
|
||||
|
||||
scrobbles = append(scrobbles, Scrobble{
|
||||
UserId: userId,
|
||||
Timestamp: ts,
|
||||
SongName: item.Track.Name,
|
||||
Artist: artistName,
|
||||
Album: item.Track.Album.Name,
|
||||
MsPlayed: item.Track.DurationMs,
|
||||
Platform: "spotify",
|
||||
})
|
||||
}
|
||||
|
||||
SaveScrobbles(scrobbles)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func userIdToInt(s string) int {
|
||||
var id int
|
||||
fmt.Sscanf(s, "%d", &id)
|
||||
return id
|
||||
}
|
||||
|
||||
func getBaseURL(r *http.Request) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
return scheme + "://" + r.Host
|
||||
}
|
||||
|
||||
func GetSpotifyAuthURL(userId int, baseURL string) (string, error) {
|
||||
clientId, _, _, _, _, err := GetUserSpotifyCredentials(userId)
|
||||
if err != nil || clientId == "" {
|
||||
return "", fmt.Errorf("Spotify credentials not configured")
|
||||
}
|
||||
|
||||
redirectURI := baseURL + "/scrobble/spotify/callback"
|
||||
scope := "user-read-currently-playing user-read-recently-played"
|
||||
|
||||
return fmt.Sprintf("%s?client_id=%s&response_type=code&redirect_uri=%s&scope=%s&state=%d",
|
||||
SpotifyAuthURL, url.QueryEscape(clientId), url.QueryEscape(redirectURI), url.QueryEscape(scope), userId), nil
|
||||
}
|
||||
|
||||
type LastTrack struct {
|
||||
UserId int
|
||||
TrackId string
|
||||
SongName string
|
||||
Artist string
|
||||
AlbumName string
|
||||
DurationMs int
|
||||
ProgressMs int
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func GetLastTrack(userId int) (*LastTrack, error) {
|
||||
var track LastTrack
|
||||
err := db.Pool.QueryRow(context.Background(),
|
||||
`SELECT user_id, track_id, song_name, artist, album_name, duration_ms, progress_ms, updated_at
|
||||
FROM spotify_last_track WHERE user_id = $1`,
|
||||
userId).Scan(&track.UserId, &track.TrackId, &track.SongName, &track.Artist,
|
||||
&track.AlbumName, &track.DurationMs, &track.ProgressMs, &track.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &track, nil
|
||||
}
|
||||
|
||||
func SetLastTrack(userId int, trackId, songName, artist, albumName string, durationMs, progressMs int) error {
|
||||
_, err := db.Pool.Exec(context.Background(),
|
||||
`INSERT INTO spotify_last_track (user_id, track_id, song_name, artist, album_name, duration_ms, progress_ms, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
track_id = $2, song_name = $3, artist = $4, album_name = $5, duration_ms = $6, progress_ms = $7, updated_at = NOW()`,
|
||||
userId, trackId, songName, artist, albumName, durationMs, progressMs)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error saving last track: %v\n", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkAndScrobbleHalfway(userId int, currentTrack *SpotifyTrack, progressMs int) {
|
||||
if currentTrack.Id == "" || currentTrack.DurationMs == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
lastTrack, err := GetLastTrack(userId)
|
||||
if err != nil {
|
||||
if err.Error() == "no rows in result set" {
|
||||
SetLastTrack(userId, currentTrack.Id, currentTrack.Name,
|
||||
getArtistName(currentTrack.Artists), currentTrack.Album.Name, currentTrack.DurationMs, progressMs)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if lastTrack.TrackId != currentTrack.Id {
|
||||
if lastTrack.DurationMs > 0 {
|
||||
percentagePlayed := float64(lastTrack.ProgressMs) / float64(lastTrack.DurationMs)
|
||||
if percentagePlayed >= 0.5 || lastTrack.ProgressMs >= 240000 {
|
||||
msPlayed := lastTrack.ProgressMs
|
||||
if msPlayed > lastTrack.DurationMs {
|
||||
msPlayed = lastTrack.DurationMs
|
||||
}
|
||||
scrobble := Scrobble{
|
||||
UserId: userId,
|
||||
Timestamp: lastTrack.UpdatedAt,
|
||||
SongName: lastTrack.SongName,
|
||||
Artist: lastTrack.Artist,
|
||||
Album: lastTrack.AlbumName,
|
||||
MsPlayed: msPlayed,
|
||||
Platform: "spotify",
|
||||
}
|
||||
SaveScrobble(scrobble)
|
||||
}
|
||||
}
|
||||
|
||||
SetLastTrack(userId, currentTrack.Id, currentTrack.Name,
|
||||
getArtistName(currentTrack.Artists), currentTrack.Album.Name, currentTrack.DurationMs, progressMs)
|
||||
} else {
|
||||
SetLastTrack(userId, currentTrack.Id, currentTrack.Name,
|
||||
getArtistName(currentTrack.Artists), currentTrack.Album.Name, currentTrack.DurationMs, progressMs)
|
||||
}
|
||||
}
|
||||
|
||||
func getArtistName(artists []SpotifyArtist) string {
|
||||
if len(artists) > 0 {
|
||||
return artists[0].Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
4
static/assets/icons/add.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 259 B |
11
static/assets/icons/logout.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 257 B |
13
static/assets/icons/settings.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<!--
|
||||
version: "2.0"
|
||||
unicode: "f69e"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M14.647 4.081a.724 .724 0 0 0 1.08 .448c2.439 -1.485 5.23 1.305 3.745 3.744a.724 .724 0 0 0 .447 1.08c2.775 .673 2.775 4.62 0 5.294a.724 .724 0 0 0 -.448 1.08c1.485 2.439 -1.305 5.23 -3.744 3.745a.724 .724 0 0 0 -1.08 .447c-.673 2.775 -4.62 2.775 -5.294 0a.724 .724 0 0 0 -1.08 -.448c-2.439 1.485 -5.23 -1.305 -3.745 -3.744a.724 .724 0 0 0 -.447 -1.08c-2.775 -.673 -2.775 -4.62 0 -5.294a.724 .724 0 0 0 .448 -1.08c-1.485 -2.439 1.305 -5.23 3.744 -3.745a.722 .722 0 0 0 1.08 -.447c.673 -2.775 4.62 -2.775 5.294 0zm-2.647 4.919a3 3 0 1 0 0 6a3 3 0 0 0 0 -6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 732 B |
14
static/assets/icons/user.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<!--
|
||||
version: "2.39"
|
||||
unicode: "fd19"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12 2a5 5 0 1 1 -5 5l.005 -.217a5 5 0 0 1 4.995 -4.783z" />
|
||||
<path d="M14 14a5 5 0 0 1 5 5v1a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-1a5 5 0 0 1 5 -5h4z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 328 B |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.7 KiB |
BIN
static/assets/pfps/default_album.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
static/assets/pfps/default_artist.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
83
static/import.js
Normal file
@@ -0,0 +1,83 @@
|
||||
function handleImport(formId, progressPrefix, endpoint, progressUrl, formatLabel) {
|
||||
const form = document.getElementById(formId);
|
||||
const progressContainer = document.getElementById(progressPrefix + '-progress');
|
||||
const progressFill = document.getElementById(progressPrefix + '-progress-fill');
|
||||
const progressText = document.getElementById(progressPrefix + '-progress-text');
|
||||
const progressStatus = document.getElementById(progressPrefix + '-progress-status');
|
||||
const progressTracks = document.getElementById(progressPrefix + '-progress-tracks');
|
||||
const progressError = document.getElementById(progressPrefix + '-progress-error');
|
||||
const progressSuccess = document.getElementById(progressPrefix + '-progress-success');
|
||||
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Reset and show progress
|
||||
progressFill.style.width = '0%';
|
||||
progressFill.classList.add('animating');
|
||||
progressText.textContent = '0%';
|
||||
progressStatus.textContent = 'Starting import...';
|
||||
progressTracks.textContent = '';
|
||||
progressError.textContent = '';
|
||||
progressSuccess.textContent = '';
|
||||
progressContainer.style.display = 'block';
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: progressPrefix === 'lastfm'
|
||||
? new URLSearchParams(new FormData(form))
|
||||
: new FormData(form)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to start import: ' + response.statusText);
|
||||
|
||||
const { job_id } = await response.json();
|
||||
const eventSource = new EventSource(progressUrl + job_id);
|
||||
|
||||
eventSource.onmessage = function(event) {
|
||||
const update = JSON.parse(event.data);
|
||||
if (update.status === 'connected') return;
|
||||
|
||||
if (update.total_pages > 0) {
|
||||
const completed = update.completed_pages || update.current_page || 0;
|
||||
const percent = Math.round((completed / update.total_pages) * 100);
|
||||
progressFill.style.width = percent + '%';
|
||||
progressText.textContent = percent + '%';
|
||||
progressStatus.textContent = 'Processing ' + formatLabel + ' ' + completed + ' of ' + update.total_pages;
|
||||
}
|
||||
|
||||
if (update.tracks_imported !== undefined) {
|
||||
progressTracks.textContent = update.tracks_imported.toLocaleString() + ' tracks imported';
|
||||
}
|
||||
|
||||
if (update.status === 'completed') {
|
||||
progressFill.classList.remove('animating');
|
||||
progressStatus.textContent = 'Import completed!';
|
||||
progressSuccess.textContent = 'Successfully imported ' + update.tracks_imported.toLocaleString() + ' tracks from ' + (progressPrefix === 'spotify' ? 'Spotify' : 'Last.fm');
|
||||
eventSource.close();
|
||||
form.reset();
|
||||
} else if (update.status === 'error') {
|
||||
progressFill.classList.remove('animating');
|
||||
progressStatus.textContent = 'Import failed';
|
||||
progressError.textContent = 'Error: ' + (update.error || 'Unknown error');
|
||||
eventSource.close();
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = function() {
|
||||
progressFill.classList.remove('animating');
|
||||
progressStatus.textContent = 'Connection error';
|
||||
progressError.textContent = 'Lost connection to server. The import may still be running in the background.';
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
progressFill.classList.remove('animating');
|
||||
progressStatus.textContent = 'Import failed';
|
||||
progressError.textContent = 'Error: ' + err.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleImport('spotify-form', 'spotify', '/import/spotify', '/import/spotify/progress?job=', 'batch');
|
||||
handleImport('lastfm-form', 'lastfm', '/import/lastfm', '/import/lastfm/progress?job=', 'page');
|
||||
195
static/menu.js
Normal file
@@ -0,0 +1,195 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const menuButton = document.getElementById('menuButton');
|
||||
const sideMenu = document.getElementById('sideMenu');
|
||||
const menuOverlay = document.getElementById('menuOverlay');
|
||||
|
||||
function toggleMenu() {
|
||||
menuButton.classList.toggle('active');
|
||||
sideMenu.classList.toggle('active');
|
||||
menuOverlay.classList.toggle('active');
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menuButton.classList.remove('active');
|
||||
sideMenu.classList.remove('active');
|
||||
menuOverlay.classList.remove('active');
|
||||
}
|
||||
|
||||
if (menuButton) {
|
||||
menuButton.addEventListener('click', toggleMenu);
|
||||
}
|
||||
|
||||
if (menuOverlay) {
|
||||
menuOverlay.addEventListener('click', closeMenu);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeMenu();
|
||||
closeEditModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Global Search
|
||||
const searchInput = document.getElementById('globalSearch');
|
||||
const searchResults = document.getElementById('searchResults');
|
||||
let searchTimeout;
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function(e) {
|
||||
const query = e.target.value.trim();
|
||||
|
||||
clearTimeout(searchTimeout);
|
||||
|
||||
if (query.length < 1) {
|
||||
searchResults.classList.remove('active');
|
||||
searchResults.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(function() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/search?q=' + encodeURIComponent(query), true);
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4) {
|
||||
if (xhr.status === 200) {
|
||||
var results = JSON.parse(xhr.responseText);
|
||||
if (results.length === 0) {
|
||||
searchResults.innerHTML = '<div class="search-result-item"><span class="search-result-name">No results</span></div>';
|
||||
} else {
|
||||
var html = '';
|
||||
for (var i = 0; i < results.length; i++) {
|
||||
var r = results[i];
|
||||
html += '<a href="' + r.url + '" class="search-result-item">' +
|
||||
'<div class="search-result-info">' +
|
||||
'<span class="search-result-name">' + r.name + '</span>' +
|
||||
'<span class="search-result-type">' + r.type + '</span>' +
|
||||
'</div>' +
|
||||
'<span class="search-result-count">' + r.count + '</span>' +
|
||||
'</a>';
|
||||
}
|
||||
searchResults.innerHTML = html;
|
||||
}
|
||||
searchResults.classList.add('active');
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
searchResults.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) {
|
||||
searchResults.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Image Upload Functionality
|
||||
document.querySelectorAll('.editable-image').forEach(function(img) {
|
||||
img.style.cursor = 'pointer';
|
||||
img.addEventListener('click', function(e) {
|
||||
var entityType = this.getAttribute('data-entity');
|
||||
var entityId = this.getAttribute('data-id');
|
||||
var field = this.getAttribute('data-field');
|
||||
|
||||
var input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
|
||||
input.onchange = function(e) {
|
||||
var file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('File exceeds 5MB limit');
|
||||
return;
|
||||
}
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/upload/image', true);
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4) {
|
||||
if (xhr.status === 200) {
|
||||
var result = JSON.parse(xhr.responseText);
|
||||
|
||||
var patchXhr = new XMLHttpRequest();
|
||||
patchXhr.open('PATCH', '/api/' + entityType + '/' + entityId + '/edit?field=' + field, true);
|
||||
patchXhr.setRequestHeader('Content-Type', 'application/json');
|
||||
patchXhr.onreadystatechange = function() {
|
||||
if (patchXhr.readyState === 4) {
|
||||
if (patchXhr.status === 200) {
|
||||
img.src = result.url;
|
||||
} else {
|
||||
alert('Error updating image: ' + patchXhr.responseText);
|
||||
}
|
||||
}
|
||||
};
|
||||
patchXhr.send(JSON.stringify({ value: result.url }));
|
||||
} else {
|
||||
alert('Error uploading: ' + xhr.responseText);
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.send(formData);
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
});
|
||||
|
||||
// Generic edit form handler
|
||||
var editForm = document.getElementById('editForm');
|
||||
if (editForm) {
|
||||
editForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var form = e.target;
|
||||
var entityType = form.getAttribute('data-entity');
|
||||
var entityId = form.getAttribute('data-id');
|
||||
|
||||
var data = {};
|
||||
var elements = form.querySelectorAll('input, textarea');
|
||||
for (var i = 0; i < elements.length; i++) {
|
||||
var el = elements[i];
|
||||
if (el.name) {
|
||||
data[el.name] = el.value;
|
||||
}
|
||||
}
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('PATCH', '/api/' + entityType + '/' + entityId + '/batch', true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4) {
|
||||
if (xhr.status === 200) {
|
||||
var response = JSON.parse(xhr.responseText);
|
||||
if (response.success && entityType === 'song' && response.artist && response.title && response.username) {
|
||||
var newUrl = '/profile/' + response.username + '/song/' + encodeURIComponent(response.artist) + '/' + encodeURIComponent(response.title);
|
||||
window.location.href = newUrl;
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
} else {
|
||||
alert('Error saving: ' + xhr.responseText);
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.send(JSON.stringify(data));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function openEditModal() {
|
||||
document.getElementById('editModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
document.getElementById('editModal').style.display = 'none';
|
||||
}
|
||||
133
static/profile.js
Normal file
@@ -0,0 +1,133 @@
|
||||
function updateTopArtists() {
|
||||
const period = document.getElementById('period-select').value;
|
||||
const limit = document.getElementById('limit-select').value;
|
||||
const view = document.getElementById('view-select').value;
|
||||
|
||||
const customDates = document.getElementById('custom-dates');
|
||||
if (period === 'custom') {
|
||||
customDates.style.display = 'inline-block';
|
||||
} else {
|
||||
customDates.style.display = 'none';
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('period', period);
|
||||
params.set('limit', limit);
|
||||
params.set('view', view);
|
||||
|
||||
if (period === 'custom') {
|
||||
const startDate = document.getElementById('start-date').value;
|
||||
const endDate = document.getElementById('end-date').value;
|
||||
if (startDate) params.set('start', startDate);
|
||||
if (endDate) params.set('end', endDate);
|
||||
}
|
||||
|
||||
window.location.search = params.toString();
|
||||
}
|
||||
|
||||
function updateLimitOptions() {
|
||||
const view = document.getElementById('view-select').value;
|
||||
const limitSelect = document.getElementById('limit-select');
|
||||
const maxLimit = view === 'grid' ? 8 : 30;
|
||||
|
||||
for (let option of limitSelect.options) {
|
||||
const value = parseInt(option.value);
|
||||
if (value > maxLimit || (view === 'grid' && value === 7)) {
|
||||
option.style.display = 'none';
|
||||
} else {
|
||||
option.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (parseInt(limitSelect.value) > maxLimit || (view === 'grid' && parseInt(limitSelect.value) === 7)) {
|
||||
limitSelect.value = maxLimit;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTopAlbums() {
|
||||
const period = document.getElementById('album-period-select').value;
|
||||
const limit = document.getElementById('album-limit-select').value;
|
||||
const view = document.getElementById('album-view-select').value;
|
||||
|
||||
const customDates = document.getElementById('album-custom-dates');
|
||||
if (period === 'custom') {
|
||||
customDates.style.display = 'inline-block';
|
||||
} else {
|
||||
customDates.style.display = 'none';
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('album_period', period);
|
||||
params.set('album_limit', limit);
|
||||
params.set('album_view', view);
|
||||
|
||||
if (period === 'custom') {
|
||||
const startDate = document.getElementById('album-start-date').value;
|
||||
const endDate = document.getElementById('album-end-date').value;
|
||||
if (startDate) params.set('album_start', startDate);
|
||||
if (endDate) params.set('album_end', endDate);
|
||||
}
|
||||
|
||||
window.location.search = params.toString();
|
||||
}
|
||||
|
||||
function updateTopAlbumsLimitOptions() {
|
||||
const view = document.getElementById('album-view-select').value;
|
||||
const limitSelect = document.getElementById('album-limit-select');
|
||||
const maxLimit = view === 'grid' ? 8 : 30;
|
||||
|
||||
for (let option of limitSelect.options) {
|
||||
const value = parseInt(option.value);
|
||||
if (value > maxLimit || (view === 'grid' && value === 7)) {
|
||||
option.style.display = 'none';
|
||||
} else {
|
||||
option.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (parseInt(limitSelect.value) > maxLimit || (view === 'grid' && parseInt(limitSelect.value) === 7)) {
|
||||
limitSelect.value = maxLimit;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTopTracks() {
|
||||
const period = document.getElementById('track-period-select').value;
|
||||
const limit = document.getElementById('track-limit-select').value;
|
||||
|
||||
const customDates = document.getElementById('track-custom-dates');
|
||||
if (period === 'custom') {
|
||||
customDates.style.display = 'inline-block';
|
||||
} else {
|
||||
customDates.style.display = 'none';
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('track_period', period);
|
||||
params.set('track_limit', limit);
|
||||
|
||||
if (period === 'custom') {
|
||||
const startDate = document.getElementById('track-start-date').value;
|
||||
const endDate = document.getElementById('track-end-date').value;
|
||||
if (startDate) params.set('track_start', startDate);
|
||||
if (endDate) params.set('track_end', endDate);
|
||||
}
|
||||
|
||||
window.location.search = params.toString();
|
||||
}
|
||||
|
||||
function syncGridHeights() {}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const customDates = document.getElementById('custom-dates');
|
||||
const periodSelect = document.getElementById('period-select');
|
||||
|
||||
if (periodSelect && customDates) {
|
||||
if (periodSelect.value === 'custom') {
|
||||
customDates.style.display = 'inline-block';
|
||||
}
|
||||
}
|
||||
|
||||
updateLimitOptions();
|
||||
|
||||
updateTopAlbumsLimitOptions();
|
||||
});
|
||||
1174
static/style.css
73
templates/album.gohtml
Normal file
@@ -0,0 +1,73 @@
|
||||
{{define "album"}}
|
||||
<div class="profile-top">
|
||||
{{if .Album.CoverUrl}}
|
||||
<img class="editable-image album-cover" data-entity="album" data-id="{{.Album.Id}}" data-field="cover_url" src="{{.Album.CoverUrl}}" alt="{{.Album.Title}}'s cover">
|
||||
{{else}}
|
||||
<img class="editable-image album-cover" data-entity="album" data-id="{{.Album.Id}}" data-field="cover_url" src="/files/assets/pfps/default_album.png" alt="{{.Album.Title}}'s cover">
|
||||
{{end}}
|
||||
<div class="username-bio">
|
||||
<h1>
|
||||
{{.Album.Title}}
|
||||
{{if eq .LoggedInUsername .Username}}
|
||||
<button class="edit-btn" onclick="openEditModal()">Edit</button>
|
||||
{{end}}
|
||||
</h1>
|
||||
{{if .ArtistNames}}
|
||||
<h2>
|
||||
{{- range $i, $name := .ArtistNames}}{{if $i}}, {{end}}<a href="/profile/{{$.Username}}/artist/{{urlquery $name}}">{{$name}}</a>{{end}}
|
||||
</h2>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="profile-top-blank">
|
||||
</div>
|
||||
<div class="user-stats-top">
|
||||
<h3>{{formatInt .ListenCount}}</h3> <p>Listens<p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history">
|
||||
<h3>Scrobbles</h3>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Artist</th>
|
||||
<th>Title</th>
|
||||
<th>Album</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
{{$username := .Username}}
|
||||
{{range .Times}}
|
||||
<tr>
|
||||
<td>
|
||||
{{- $artistNames := getArtistNames .ArtistIds}}
|
||||
{{- range $i, $name := $artistNames}}{{if $i}}, {{end}}<a href="/profile/{{$username}}/artist/{{urlquery $name}}">{{$name}}</a>{{end}}
|
||||
</td>
|
||||
<td><a href="/profile/{{$username}}/song/{{urlquery .ArtistName}}/{{urlquery .SongName}}">{{.SongName}}</a></td>
|
||||
<td>{{.AlbumName}}</td>
|
||||
<td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
<div class="page_buttons">
|
||||
{{if gt .Page 1 }}
|
||||
<a href="/profile/{{.Username}}/album/{{urlquery .Artist.Name}}/{{urlquery .Album.Title}}?page={{sub .Page 1}}">Prev Page</a>
|
||||
{{end}}
|
||||
<a href="/profile/{{.Username}}/album/{{urlquery .Artist.Name}}/{{urlquery .Album.Title}}?page={{add .Page 1}}">Next Page</a>
|
||||
</div>
|
||||
|
||||
{{if eq .LoggedInUsername .Username}}
|
||||
<div id="editModal" class="modal-overlay" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<h2>Edit Album</h2>
|
||||
<form id="editForm" data-entity="album" data-id="{{.Album.Id}}">
|
||||
<label>Title: <input type="text" name="title" value="{{.Album.Title}}"></label>
|
||||
<label>Spotify ID: <input type="text" name="spotify_id" value="{{.Album.SpotifyId}}"></label>
|
||||
<label>MusicBrainz ID: <input type="text" name="musicbrainz_id" value="{{.Album.MusicbrainzId}}"></label>
|
||||
<div class="modal-buttons">
|
||||
<button type="button" class="cancel-btn" onclick="closeEditModal()">Cancel</button>
|
||||
<button type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
73
templates/artist.gohtml
Normal file
@@ -0,0 +1,73 @@
|
||||
{{define "artist"}}
|
||||
<div class="profile-top">
|
||||
{{if .Artist.ImageUrl}}
|
||||
<img class="editable-image" data-entity="artist" data-id="{{.Artist.Id}}" data-field="image_url" src="{{.Artist.ImageUrl}}" alt="{{.Artist.Name}}'s image">
|
||||
{{else}}
|
||||
<img class="editable-image" data-entity="artist" data-id="{{.Artist.Id}}" data-field="image_url" src="/files/assets/pfps/default_artist.png" alt="{{.Artist.Name}}'s image">
|
||||
{{end}}
|
||||
<div class="username-bio">
|
||||
<h1>
|
||||
{{.Artist.Name}}
|
||||
{{if eq .LoggedInUsername .Username}}
|
||||
<button class="edit-btn" onclick="openEditModal()">Edit</button>
|
||||
{{end}}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="profile-top-blank">
|
||||
</div>
|
||||
<div class="user-stats-top">
|
||||
<h3>{{formatInt .ListenCount}}</h3> <p>Listens<p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history">
|
||||
<h3>Scrobbles</h3>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Artist</th>
|
||||
<th>Title</th>
|
||||
<th>Album</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
{{$username := .Username}}
|
||||
{{range .Times}}
|
||||
<tr>
|
||||
<td>
|
||||
{{- $artistNames := getArtistNames .ArtistIds}}
|
||||
{{- range $i, $name := $artistNames}}{{if $i}}, {{end}}<a href="/profile/{{$username}}/artist/{{urlquery $name}}">{{$name}}</a>{{end}}
|
||||
</td>
|
||||
<td><a href="/profile/{{$username}}/song/{{urlquery .ArtistName}}/{{urlquery .SongName}}">{{.SongName}}</a></td>
|
||||
<td><a href="/profile/{{$username}}/album/{{urlquery .ArtistName}}/{{urlquery .AlbumName}}">{{.AlbumName}}</a></td>
|
||||
<td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
<div class="page_buttons">
|
||||
{{if gt .Page 1 }}
|
||||
<a href="/profile/{{.Username}}/artist/{{urlquery .Artist.Name}}?page={{sub .Page 1}}">Prev Page</a>
|
||||
{{end}}
|
||||
<a href="/profile/{{.Username}}/artist/{{urlquery .Artist.Name}}?page={{add .Page 1}}">Next Page</a>
|
||||
</div>
|
||||
<div class="bio-box">
|
||||
<h3>Bio</h3>
|
||||
<p id="bio-display">{{.Artist.Bio}}</p>
|
||||
</div>
|
||||
|
||||
{{if eq .LoggedInUsername .Username}}
|
||||
<div id="editModal" class="modal-overlay" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<h2>Edit Artist</h2>
|
||||
<form id="editForm" data-entity="artist" data-id="{{.Artist.Id}}">
|
||||
<label>Name: <input type="text" name="name" value="{{.Artist.Name}}"></label>
|
||||
<label>Bio: <textarea name="bio">{{.Artist.Bio}}</textarea></label>
|
||||
<label>Spotify ID: <input type="text" name="spotify_id" value="{{.Artist.SpotifyId}}"></label>
|
||||
<label>MusicBrainz ID: <input type="text" name="musicbrainz_id" value="{{.Artist.MusicbrainzId}}"></label>
|
||||
<div class="modal-buttons">
|
||||
<button type="button" class="cancel-btn" onclick="closeEditModal()">Cancel</button>
|
||||
<button type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
72
templates/base.gohtml
Normal file
@@ -0,0 +1,72 @@
|
||||
{{define "base"}}
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="/files/style.css" type="text/css">
|
||||
<title>{{.Title}}</title>
|
||||
{{block "head" .}}{{end}}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Hamburger Menu Button -->
|
||||
<div class="menu-button" id="menuButton">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="search-container">
|
||||
<input type="text" id="globalSearch" placeholder="Search artists, songs, albums..." autocomplete="off">
|
||||
<div id="searchResults" class="search-results"></div>
|
||||
</div>
|
||||
|
||||
<!-- Slide-out Menu -->
|
||||
<div class="side-menu" id="sideMenu">
|
||||
<div class="menu-header">
|
||||
<h3>muzi</h3>
|
||||
</div>
|
||||
<nav class="menu-nav">
|
||||
{{if .LoggedInUsername}}
|
||||
<a href="/profile/{{.LoggedInUsername}}" class="menu-item">
|
||||
<img src="/files/assets/icons/user.svg" class="menu-icon" alt="Profile">
|
||||
<span>My Profile</span>
|
||||
</a>
|
||||
<a href="/logout" class="menu-item">
|
||||
<img src="/files/assets/icons/logout.svg" class="menu-icon" alt="Logout">
|
||||
<span>Logout</span>
|
||||
</a>
|
||||
{{else}}
|
||||
<a href="/login" class="menu-item">
|
||||
<img src="/files/assets/icons/user.svg" class="menu-icon" alt="Login">
|
||||
<span>Login</span>
|
||||
</a>
|
||||
{{end}}
|
||||
<a href="/settings" class="menu-item">
|
||||
<img src="/files/assets/icons/settings.svg" class="menu-icon" alt="Settings">
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
<a href="/scrobble" class="menu-item">
|
||||
<img src="/files/assets/icons/add.svg" class="menu-icon" alt="Scrobble">
|
||||
<span>Manual Scrobble</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Overlay for closing menu -->
|
||||
<div class="menu-overlay" id="menuOverlay"></div>
|
||||
|
||||
<!-- Main Content -->
|
||||
{{ if eq .TemplateName "profile"}}{{block "profile" .}}{{end}}{{end}}
|
||||
{{ if eq .TemplateName "settings"}}{{block "settings" .}}{{end}}{{end}}
|
||||
{{ if eq .TemplateName "artist"}}{{block "artist" .}}{{end}}{{end}}
|
||||
{{ if eq .TemplateName "song"}}{{block "song" .}}{{end}}{{end}}
|
||||
{{ if eq .TemplateName "album"}}{{block "album" .}}{{end}}{{end}}
|
||||
{{ if eq .TemplateName "scrobble"}}{{block "scrobble" .}}{{end}}{{end}}
|
||||
|
||||
<script src="/files/menu.js"></script>
|
||||
{{if eq .TemplateName "profile"}}
|
||||
<script src="/files/profile.js"></script>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -14,6 +14,26 @@
|
||||
<label for="pass">Password:</label>
|
||||
<input type="password" id="pass" name="pass"> <br> <br>
|
||||
<input type="submit" value="Create Account">
|
||||
{{if eq .Error "length"}}
|
||||
<div class="login-error">
|
||||
Password must be 8-64 chars (inclusive).
|
||||
</div>
|
||||
{{end}}
|
||||
{{if eq .Error "session"}}
|
||||
<div class="login-error">
|
||||
Unable to create session. Please try again.
|
||||
</div>
|
||||
{{end}}
|
||||
{{if eq .Error "userlength"}}
|
||||
<div class="login-error">
|
||||
Username length must be greater than 0.
|
||||
</div>
|
||||
{{end}}
|
||||
{{if eq .Error "usertaken"}}
|
||||
<div class="login-error">
|
||||
Username must be unique. Please try again.
|
||||
</div>
|
||||
{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
63
templates/import.gohtml
Normal file
@@ -0,0 +1,63 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="/files/style.css" type="text/css">
|
||||
<title>muzi | Import Data</title>
|
||||
<style>
|
||||
/* Debug: Force bar visibility */
|
||||
#progress-fill {
|
||||
background: linear-gradient(90deg, #0f0, #0a0) !important;
|
||||
min-width: 2px !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="import-container">
|
||||
<h1>Import Your Listening Data</h1>
|
||||
<p>Welcome, {{.Username}}!</p>
|
||||
|
||||
<div class="import-section">
|
||||
<h2>Spotify</h2>
|
||||
<p>Import your Spotify listening history from your data export.</p>
|
||||
<form id="spotify-form" method="POST" action="/import/spotify" enctype="multipart/form-data">
|
||||
<input type="file" name="json_files" accept=".json,application/json" multiple required>
|
||||
<button type="submit">Upload Spotify Data</button>
|
||||
</form>
|
||||
|
||||
<div id="spotify-progress" class="progress-container" style="display: none;">
|
||||
<div class="progress-status" id="spotify-progress-status">Initializing...</div>
|
||||
<div class="progress-bar-wrapper">
|
||||
<div class="progress-bar-fill" id="spotify-progress-fill"></div>
|
||||
<div class="progress-text" id="spotify-progress-text">0%</div>
|
||||
</div>
|
||||
<div class="progress-tracks" id="spotify-progress-tracks"></div>
|
||||
<div class="progress-error" id="spotify-progress-error"></div>
|
||||
<div class="progress-success" id="spotify-progress-success"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="import-section">
|
||||
<h2>Last.fm</h2>
|
||||
<p>Import your Last.fm scrobbles.</p>
|
||||
<form id="lastfm-form" method="POST" action="/import/lastfm">
|
||||
<input type="text" name="lastfm_username" placeholder="Last.FM Username" required>
|
||||
<input type="text" name="lastfm_api_key" placeholder="Last.FM API Key" required>
|
||||
<button type="submit">Import from Last.fm</button>
|
||||
</form>
|
||||
|
||||
<div id="lastfm-progress" class="progress-container" style="display: none;">
|
||||
<div class="progress-status" id="lastfm-progress-status">Initializing...</div>
|
||||
<div class="progress-bar-wrapper">
|
||||
<div class="progress-bar-fill" id="lastfm-progress-fill"></div>
|
||||
<div class="progress-text" id="lastfm-progress-text">0%</div>
|
||||
</div>
|
||||
<div class="progress-tracks" id="lastfm-progress-tracks"></div>
|
||||
<div class="progress-error" id="lastfm-progress-error"></div>
|
||||
<div class="progress-success" id="lastfm-progress-success"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/files/import.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -14,11 +14,16 @@
|
||||
<label for="pass">Password:</label>
|
||||
<input type="password" id="pass" name="pass"> <br> <br>
|
||||
<input type="submit" value="Login">
|
||||
{{if .ShowError}}
|
||||
{{if eq .Error "invalid-creds"}}
|
||||
<div class="login-error">
|
||||
Invalid credentials. Please try again.
|
||||
</div>
|
||||
{{end}}
|
||||
{{if eq .Error "session"}}
|
||||
<div class="login-error">
|
||||
Unable to create session. Please try again.
|
||||
</div>
|
||||
{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -1,22 +1,359 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="/files/style.css" type="text/css">
|
||||
<title>
|
||||
muzi | {{.Username}}'s Profile
|
||||
</title>
|
||||
</head>
|
||||
<body>
|
||||
{{define "profile"}}
|
||||
<div class="profile-top">
|
||||
<img src="{{.Pfp}}" alt="{{.Username}}'s avatar">
|
||||
<div class="username-bio">
|
||||
<h1>{{.Username}}</h1>
|
||||
<h2>{{.Bio}}</h2>
|
||||
</div>
|
||||
<div class="user-stats-top">
|
||||
<h3>{{.ScrobbleCount}} Listens</h3>
|
||||
<h3>{{.ArtistCount}} Artists</h3>
|
||||
<div class="profile-top-blank">
|
||||
</div>
|
||||
<div class="user-stats-top">
|
||||
<h3>{{formatInt .ScrobbleCount}}</h3> <p>Listens<p>
|
||||
<h3>{{formatInt .TrackCount}}</h3> <p>Unique Tracks<p>
|
||||
<h3>{{formatInt .ArtistCount}}</h3> <p>Artists<p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-artists">
|
||||
<div class="top-artists-controls">
|
||||
<h3>Top Artists</h3>
|
||||
<div class="controls-row">
|
||||
<label>
|
||||
Period:
|
||||
<select id="period-select" onchange="updateTopArtists()">
|
||||
<option value="all_time" {{if eq .TopArtistsPeriod "all_time"}}selected{{end}}>All Time</option>
|
||||
<option value="week" {{if eq .TopArtistsPeriod "week"}}selected{{end}}>Last 7 Days</option>
|
||||
<option value="month" {{if eq .TopArtistsPeriod "month"}}selected{{end}}>Last 30 Days</option>
|
||||
<option value="year" {{if eq .TopArtistsPeriod "year"}}selected{{end}}>Last Year</option>
|
||||
<option value="custom" {{if eq .TopArtistsPeriod "custom"}}selected{{end}}>Custom</option>
|
||||
</select>
|
||||
</label>
|
||||
<div id="custom-dates" style="display: {{if eq .TopArtistsPeriod "custom"}}inline-block{{else}}none{{end}};">
|
||||
<input type="date" id="start-date" onchange="updateTopArtists()">
|
||||
<input type="date" id="end-date" onchange="updateTopArtists()">
|
||||
</div>
|
||||
<label>
|
||||
Count:
|
||||
<select id="limit-select" onchange="updateTopArtists()">
|
||||
<option value="5" {{if eq .TopArtistsLimit 5}}selected{{end}}>5</option>
|
||||
<option value="6" {{if eq .TopArtistsLimit 6}}selected{{end}}>6</option>
|
||||
<option value="7" {{if eq .TopArtistsLimit 7}}selected{{end}}>7</option>
|
||||
<option value="8" {{if eq .TopArtistsLimit 8}}selected{{end}}>8</option>
|
||||
<option value="9" {{if eq .TopArtistsLimit 9}}selected{{end}}>9</option>
|
||||
<option value="10" {{if eq .TopArtistsLimit 10}}selected{{end}}>10</option>
|
||||
<option value="15" {{if eq .TopArtistsLimit 15}}selected{{end}}>15</option>
|
||||
<option value="20" {{if eq .TopArtistsLimit 20}}selected{{end}}>20</option>
|
||||
<option value="25" {{if eq .TopArtistsLimit 25}}selected{{end}}>25</option>
|
||||
<option value="30" {{if eq .TopArtistsLimit 30}}selected{{end}}>30</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
View:
|
||||
<select id="view-select" onchange="updateLimitOptions(); updateTopArtists()">
|
||||
<option value="grid" {{if eq .TopArtistsView "grid"}}selected{{end}}>Grid</option>
|
||||
<option value="list" {{if eq .TopArtistsView "list"}}selected{{end}}>List</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{{if .TopArtists}}
|
||||
<div id="top-artists-display" class="top-artists-{{.TopArtistsView}}">
|
||||
{{$view := .TopArtistsView}}
|
||||
{{$artists := .TopArtists}}
|
||||
{{$len := len $artists}}
|
||||
{{if eq $view "grid"}}
|
||||
<div class="artist-grid {{if mod $len 2}}artist-grid-odd{{end}}">
|
||||
{{if mod $len 2}}
|
||||
<div class="artist-cell-first">
|
||||
<a href="/profile/{{$.Username}}/artist/{{urlquery (index $artists 0).Artist.Name}}" class="grid-items-cover-image">
|
||||
<div class="grid-items-cover-image-image">
|
||||
{{if (index $artists 0).Artist.ImageUrl}}
|
||||
<img src="{{(index $artists 0).Artist.ImageUrl}}" alt="{{(index $artists 0).Artist.Name}}">
|
||||
{{else}}
|
||||
<div class="artist-placeholder"></div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="grid-items-item-details">
|
||||
<p class="grid-items-item-main-text">{{(index $artists 0).Artist.Name}}</p>
|
||||
<p class="grid-items-item-aux-text">{{formatInt (index $artists 0).ListenCount}} plays</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="artist-right-col">
|
||||
<div class="artist-row">
|
||||
{{range $i, $a := slice $artists 1 (add 1 (div (sub $len 1) 2))}}
|
||||
<div class="artist-cell">
|
||||
<a href="/profile/{{$.Username}}/artist/{{urlquery $a.Artist.Name}}" class="grid-items-cover-image">
|
||||
<div class="grid-items-cover-image-image">
|
||||
{{if $a.Artist.ImageUrl}}<img src="{{$a.Artist.ImageUrl}}" alt="{{$a.Artist.Name}}">{{else}}<div class="artist-placeholder"></div>{{end}}
|
||||
</div>
|
||||
<div class="grid-items-item-details">
|
||||
<p class="grid-items-item-main-text">{{$a.Artist.Name}}</p>
|
||||
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="artist-row">
|
||||
{{range $i, $a := slice $artists (add 1 (div (sub $len 1) 2)) $len}}
|
||||
<div class="artist-cell">
|
||||
<a href="/profile/{{$.Username}}/artist/{{urlquery $a.Artist.Name}}" class="grid-items-cover-image">
|
||||
<div class="grid-items-cover-image-image">
|
||||
{{if $a.Artist.ImageUrl}}<img src="{{$a.Artist.ImageUrl}}" alt="{{$a.Artist.Name}}">{{else}}<div class="artist-placeholder"></div>{{end}}
|
||||
</div>
|
||||
<div class="grid-items-item-details">
|
||||
<p class="grid-items-item-main-text">{{$a.Artist.Name}}</p>
|
||||
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="artist-row">
|
||||
{{range $i, $a := slice $artists 0 (div $len 2)}}
|
||||
<div class="artist-cell">
|
||||
<a href="/profile/{{$.Username}}/artist/{{urlquery $a.Artist.Name}}" class="grid-items-cover-image">
|
||||
<div class="grid-items-cover-image-image">
|
||||
{{if $a.Artist.ImageUrl}}<img src="{{$a.Artist.ImageUrl}}" alt="{{$a.Artist.Name}}">{{else}}<div class="artist-placeholder"></div>{{end}}
|
||||
</div>
|
||||
<div class="grid-items-item-details">
|
||||
<p class="grid-items-item-main-text">{{$a.Artist.Name}}</p>
|
||||
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="artist-row">
|
||||
{{range $i, $a := slice $artists (div $len 2) $len}}
|
||||
<div class="artist-cell">
|
||||
<a href="/profile/{{$.Username}}/artist/{{urlquery $a.Artist.Name}}" class="grid-items-cover-image">
|
||||
<div class="grid-items-cover-image-image">
|
||||
{{if $a.Artist.ImageUrl}}<img src="{{$a.Artist.ImageUrl}}" alt="{{$a.Artist.Name}}">{{else}}<div class="artist-placeholder"></div>{{end}}
|
||||
</div>
|
||||
<div class="grid-items-item-details">
|
||||
<p class="grid-items-item-main-text">{{$a.Artist.Name}}</p>
|
||||
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="artist-list">
|
||||
{{range $a := $artists}}
|
||||
<a href="/profile/{{$.Username}}/artist/{{urlquery $a.Artist.Name}}" class="artist-row">
|
||||
{{if $a.Artist.ImageUrl}}<img src="{{$a.Artist.ImageUrl}}" alt="{{$a.Artist.Name}}">{{else}}<div class="artist-placeholder-row"></div>{{end}}
|
||||
<span class="artist-name">{{$a.Artist.Name}}</span>
|
||||
<span class="artist-count">{{formatInt $a.ListenCount}} plays</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="top-albums">
|
||||
<div class="top-albums-controls">
|
||||
<h3>Top Albums</h3>
|
||||
<div class="controls-row">
|
||||
<label>
|
||||
Period:
|
||||
<select id="album-period-select" onchange="updateTopAlbums()">
|
||||
<option value="all_time" {{if eq .TopAlbumsPeriod "all_time"}}selected{{end}}>All Time</option>
|
||||
<option value="week" {{if eq .TopAlbumsPeriod "week"}}selected{{end}}>Last 7 Days</option>
|
||||
<option value="month" {{if eq .TopAlbumsPeriod "month"}}selected{{end}}>Last 30 Days</option>
|
||||
<option value="year" {{if eq .TopAlbumsPeriod "year"}}selected{{end}}>Last Year</option>
|
||||
<option value="custom" {{if eq .TopAlbumsPeriod "custom"}}selected{{end}}>Custom</option>
|
||||
</select>
|
||||
</label>
|
||||
<div id="album-custom-dates" style="display: {{if eq .TopAlbumsPeriod "custom"}}inline-block{{else}}none{{end}};">
|
||||
<input type="date" id="album-start-date" onchange="updateTopAlbums()">
|
||||
<input type="date" id="album-end-date" onchange="updateTopAlbums()">
|
||||
</div>
|
||||
<label>
|
||||
Count:
|
||||
<select id="album-limit-select" onchange="updateTopAlbums()">
|
||||
<option value="5" {{if eq .TopAlbumsLimit 5}}selected{{end}}>5</option>
|
||||
<option value="6" {{if eq .TopAlbumsLimit 6}}selected{{end}}>6</option>
|
||||
<option value="7" {{if eq .TopAlbumsLimit 7}}selected{{end}}>7</option>
|
||||
<option value="8" {{if eq .TopAlbumsLimit 8}}selected{{end}}>8</option>
|
||||
<option value="9" {{if eq .TopAlbumsLimit 9}}selected{{end}}>9</option>
|
||||
<option value="10" {{if eq .TopAlbumsLimit 10}}selected{{end}}>10</option>
|
||||
<option value="15" {{if eq .TopAlbumsLimit 15}}selected{{end}}>15</option>
|
||||
<option value="20" {{if eq .TopAlbumsLimit 20}}selected{{end}}>20</option>
|
||||
<option value="25" {{if eq .TopAlbumsLimit 25}}selected{{end}}>25</option>
|
||||
<option value="30" {{if eq .TopAlbumsLimit 30}}selected{{end}}>30</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
View:
|
||||
<select id="album-view-select" onchange="updateTopAlbumsLimitOptions(); updateTopAlbums()">
|
||||
<option value="grid" {{if eq .TopAlbumsView "grid"}}selected{{end}}>Grid</option>
|
||||
<option value="list" {{if eq .TopAlbumsView "list"}}selected{{end}}>List</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{{if .TopAlbums}}
|
||||
<div id="top-albums-display" class="top-albums-{{.TopAlbumsView}}">
|
||||
{{$view := .TopAlbumsView}}
|
||||
{{$albums := .TopAlbums}}
|
||||
{{$len := len $albums}}
|
||||
{{if eq $view "grid"}}
|
||||
<div class="artist-grid {{if mod $len 2}}artist-grid-odd{{end}}">
|
||||
{{if mod $len 2}}
|
||||
<div class="artist-cell-first">
|
||||
<a href="/profile/{{$.Username}}/album/{{urlquery (index $albums 0).Artist}}/{{urlquery (index $albums 0).AlbumName}}" class="grid-items-cover-image">
|
||||
<div class="grid-items-cover-image-image">
|
||||
{{if (index $albums 0).CoverUrl}}
|
||||
<img src="{{(index $albums 0).CoverUrl}}" alt="{{(index $albums 0).AlbumName}}">
|
||||
{{else}}
|
||||
<div class="artist-placeholder"></div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="grid-items-item-details">
|
||||
<p class="grid-items-item-main-text">{{(index $albums 0).AlbumName}}</p>
|
||||
<p class="grid-items-item-aux-text">{{(index $albums 0).Artist}}</p>
|
||||
<p class="grid-items-item-aux-text">{{formatInt (index $albums 0).ListenCount}} plays</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="artist-right-col">
|
||||
<div class="artist-row">
|
||||
{{range $i, $a := sliceAlbum $albums 1 (add 1 (div (sub $len 1) 2))}}
|
||||
<div class="artist-cell">
|
||||
<a href="/profile/{{$.Username}}/album/{{urlquery $a.Artist}}/{{urlquery $a.AlbumName}}" class="grid-items-cover-image">
|
||||
<div class="grid-items-cover-image-image">
|
||||
{{if $a.CoverUrl}}<img src="{{$a.CoverUrl}}" alt="{{$a.AlbumName}}">{{else}}<div class="artist-placeholder"></div>{{end}}
|
||||
</div>
|
||||
<div class="grid-items-item-details">
|
||||
<p class="grid-items-item-main-text">{{$a.AlbumName}}</p>
|
||||
<p class="grid-items-item-aux-text">{{$a.Artist}}</p>
|
||||
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="artist-row">
|
||||
{{range $i, $a := sliceAlbum $albums (add 1 (div (sub $len 1) 2)) $len}}
|
||||
<div class="artist-cell">
|
||||
<a href="/profile/{{$.Username}}/album/{{urlquery $a.Artist}}/{{urlquery $a.AlbumName}}" class="grid-items-cover-image">
|
||||
<div class="grid-items-cover-image-image">
|
||||
{{if $a.CoverUrl}}<img src="{{$a.CoverUrl}}" alt="{{$a.AlbumName}}">{{else}}<div class="artist-placeholder"></div>{{end}}
|
||||
</div>
|
||||
<div class="grid-items-item-details">
|
||||
<p class="grid-items-item-main-text">{{$a.AlbumName}}</p>
|
||||
<p class="grid-items-item-aux-text">{{$a.Artist}}</p>
|
||||
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="artist-row">
|
||||
{{range $i, $a := sliceAlbum $albums 0 (div $len 2)}}
|
||||
<div class="artist-cell">
|
||||
<a href="/profile/{{$.Username}}/album/{{urlquery $a.Artist}}/{{urlquery $a.AlbumName}}" class="grid-items-cover-image">
|
||||
<div class="grid-items-cover-image-image">
|
||||
{{if $a.CoverUrl}}<img src="{{$a.CoverUrl}}" alt="{{$a.AlbumName}}">{{else}}<div class="artist-placeholder"></div>{{end}}
|
||||
</div>
|
||||
<div class="grid-items-item-details">
|
||||
<p class="grid-items-item-main-text">{{$a.AlbumName}}</p>
|
||||
<p class="grid-items-item-aux-text">{{$a.Artist}}</p>
|
||||
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="artist-row">
|
||||
{{range $i, $a := sliceAlbum $albums (div $len 2) $len}}
|
||||
<div class="artist-cell">
|
||||
<a href="/profile/{{$.Username}}/album/{{urlquery $a.Artist}}/{{urlquery $a.AlbumName}}" class="grid-items-cover-image">
|
||||
<div class="grid-items-cover-image-image">
|
||||
{{if $a.CoverUrl}}<img src="{{$a.CoverUrl}}" alt="{{$a.AlbumName}}">{{else}}<div class="artist-placeholder"></div>{{end}}
|
||||
</div>
|
||||
<div class="grid-items-item-details">
|
||||
<p class="grid-items-item-main-text">{{$a.AlbumName}}</p>
|
||||
<p class="grid-items-item-aux-text">{{$a.Artist}}</p>
|
||||
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="artist-list">
|
||||
{{range $a := $albums}}
|
||||
<a href="/profile/{{$.Username}}/album/{{urlquery $a.Artist}}/{{urlquery $a.AlbumName}}" class="artist-row">
|
||||
{{if $a.CoverUrl}}<img src="{{$a.CoverUrl}}" alt="{{$a.AlbumName}}">{{else}}<div class="artist-placeholder-row"></div>{{end}}
|
||||
<span class="artist-name">{{$a.AlbumName}} - {{$a.Artist}}</span>
|
||||
<span class="artist-count">{{formatInt $a.ListenCount}} plays</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="top-tracks">
|
||||
<div class="top-tracks-controls">
|
||||
<h3>Top Tracks</h3>
|
||||
<div class="controls-row">
|
||||
<label>
|
||||
Period:
|
||||
<select id="track-period-select" onchange="updateTopTracks()">
|
||||
<option value="all_time" {{if eq .TopTracksPeriod "all_time"}}selected{{end}}>All Time</option>
|
||||
<option value="week" {{if eq .TopTracksPeriod "week"}}selected{{end}}>Last 7 Days</option>
|
||||
<option value="month" {{if eq .TopTracksPeriod "month"}}selected{{end}}>Last 30 Days</option>
|
||||
<option value="year" {{if eq .TopTracksPeriod "year"}}selected{{end}}>Last Year</option>
|
||||
<option value="custom" {{if eq .TopTracksPeriod "custom"}}selected{{end}}>Custom</option>
|
||||
</select>
|
||||
</label>
|
||||
<div id="track-custom-dates" style="display: {{if eq .TopTracksPeriod "custom"}}inline-block{{else}}none{{end}};">
|
||||
<input type="date" id="track-start-date" onchange="updateTopTracks()">
|
||||
<input type="date" id="track-end-date" onchange="updateTopTracks()">
|
||||
</div>
|
||||
<label>
|
||||
Count:
|
||||
<select id="track-limit-select" onchange="updateTopTracks()">
|
||||
<option value="5" {{if eq .TopTracksLimit 5}}selected{{end}}>5</option>
|
||||
<option value="6" {{if eq .TopTracksLimit 6}}selected{{end}}>6</option>
|
||||
<option value="7" {{if eq .TopTracksLimit 7}}selected{{end}}>7</option>
|
||||
<option value="8" {{if eq .TopTracksLimit 8}}selected{{end}}>8</option>
|
||||
<option value="9" {{if eq .TopTracksLimit 9}}selected{{end}}>9</option>
|
||||
<option value="10" {{if eq .TopTracksLimit 10}}selected{{end}}>10</option>
|
||||
<option value="15" {{if eq .TopTracksLimit 15}}selected{{end}}>15</option>
|
||||
<option value="20" {{if eq .TopTracksLimit 20}}selected{{end}}>20</option>
|
||||
<option value="25" {{if eq .TopTracksLimit 25}}selected{{end}}>25</option>
|
||||
<option value="30" {{if eq .TopTracksLimit 30}}selected{{end}}>30</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{{if .TopTracks}}
|
||||
<div id="top-tracks-display">
|
||||
{{$tracks := .TopTracks}}
|
||||
<div class="artist-list">
|
||||
{{range $t := $tracks}}
|
||||
<a href="/profile/{{$.Username}}/song/{{urlquery $t.Artist}}/{{urlquery $t.SongName}}" class="artist-row">
|
||||
<span class="artist-name">{{$t.SongName}} - {{$t.Artist}}</span>
|
||||
<span class="artist-count">{{formatInt $t.ListenCount}} plays</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="history">
|
||||
<h3>Listening History</h3>
|
||||
@@ -26,20 +363,33 @@
|
||||
<th>Title</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
{{$artists := .Artists}}
|
||||
{{if .NowPlayingTitle}}
|
||||
<tr>
|
||||
<td>{{.NowPlayingArtist}}</td>
|
||||
<td>{{.NowPlayingTitle}}</td>
|
||||
<td>Now Playing</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{$artistIdsList := .ArtistIdsList}}
|
||||
{{$times := .Times}}
|
||||
{{$username := .Username}}
|
||||
{{range $index, $title := .Titles}}
|
||||
<tr>
|
||||
<td>{{index $artists $index}}</td>
|
||||
<td>{{$title}}</td>
|
||||
<td>{{index $times $index}}</td>
|
||||
<td>
|
||||
{{- $artistIds := index $artistIdsList $index}}
|
||||
{{- $artistNames := getArtistNames $artistIds}}
|
||||
{{- range $i, $name := $artistNames}}{{if $i}}, {{end}}<a href="/profile/{{$username}}/artist/{{urlquery $name}}">{{$name}}</a>{{end}}
|
||||
</td>
|
||||
<td><a href="/profile/{{$username}}/song/{{urlquery (index $.Artists $index)}}/{{urlquery $title}}">{{$title}}</a></td>
|
||||
<td><span title="{{formatTimestampFull (index $times $index)}}">{{formatTimestamp (index $times $index)}}</span></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
<div class="page_buttons">
|
||||
<a href="/profile/{{.Username}}?page={{Sub .Page 1}}">Prev Page</a>
|
||||
<a href="/profile/{{.Username}}?page={{Add .Page 1}}">Next Page</a>
|
||||
{{if gt .Page 1 }}
|
||||
<a href="/profile/{{.Username}}?page={{sub .Page 1}}">Prev Page</a>
|
||||
{{end}}
|
||||
<a href="/profile/{{.Username}}?page={{add .Page 1}}">Next Page</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
148
templates/scrobble.gohtml
Normal file
@@ -0,0 +1,148 @@
|
||||
{{define "scrobble"}}
|
||||
<div class="scrobble-container">
|
||||
<h1>Manual Scrobble</h1>
|
||||
<p>Manually add listening history entries.</p>
|
||||
|
||||
<div id="message" class="message" style="display: none;"></div>
|
||||
|
||||
<form id="scrobble-form">
|
||||
<div id="rows-container">
|
||||
<div class="scrobble-row">
|
||||
<input type="text" name="song_name" placeholder="Song Name" required>
|
||||
<input type="text" name="artist" placeholder="Artist" required>
|
||||
<input type="text" name="album_name" placeholder="Album (optional)">
|
||||
<input type="date" name="date" required>
|
||||
<input type="time" name="time" required step="1">
|
||||
<input type="number" name="duration" placeholder="Duration (sec)" min="0">
|
||||
<button type="button" class="remove-row" style="display: none;">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scrobble-actions">
|
||||
<button type="button" id="add-row">+ Add Row</button>
|
||||
<button type="submit">Submit All</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().split('T')[0];
|
||||
const timeStr = now.toTimeString().split(' ')[0];
|
||||
|
||||
document.querySelectorAll('.scrobble-row').forEach(row => {
|
||||
row.querySelector('input[name="date"]').value = dateStr;
|
||||
row.querySelector('input[name="time"]').value = timeStr;
|
||||
});
|
||||
|
||||
document.getElementById('add-row').addEventListener('click', () => {
|
||||
const container = document.getElementById('rows-container');
|
||||
const newRow = document.createElement('div');
|
||||
newRow.className = 'scrobble-row';
|
||||
newRow.innerHTML = `
|
||||
<input type="text" name="song_name" placeholder="Song Name" required>
|
||||
<input type="text" name="artist" placeholder="Artist" required>
|
||||
<input type="text" name="album_name" placeholder="Album (optional)">
|
||||
<input type="date" name="date" required value="${dateStr}">
|
||||
<input type="time" name="time" required step="1" value="${timeStr}">
|
||||
<input type="number" name="duration" placeholder="Duration (sec)" min="0">
|
||||
<button type="button" class="remove-row">×</button>
|
||||
`;
|
||||
container.appendChild(newRow);
|
||||
updateRemoveButtons();
|
||||
});
|
||||
|
||||
document.getElementById('rows-container').addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('remove-row')) {
|
||||
e.target.closest('.scrobble-row').remove();
|
||||
updateRemoveButtons();
|
||||
}
|
||||
});
|
||||
|
||||
function updateRemoveButtons() {
|
||||
const rows = document.querySelectorAll('.scrobble-row');
|
||||
const buttons = document.querySelectorAll('.remove-row');
|
||||
buttons.forEach(btn => {
|
||||
btn.style.display = rows.length > 1 ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('scrobble-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const rows = document.querySelectorAll('.scrobble-row');
|
||||
const tracks = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const songName = row.querySelector('input[name="song_name"]').value.trim();
|
||||
const artist = row.querySelector('input[name="artist"]').value.trim();
|
||||
const albumName = row.querySelector('input[name="album_name"]').value.trim();
|
||||
const date = row.querySelector('input[name="date"]').value;
|
||||
const time = row.querySelector('input[name="time"]').value;
|
||||
const duration = row.querySelector('input[name="duration"]').value;
|
||||
|
||||
if (!songName || !artist || !date || !time) continue;
|
||||
|
||||
const timestamp = new Date(`${date}T${time}`).toISOString();
|
||||
const msPlayed = duration ? parseInt(duration) * 1000 : 0;
|
||||
|
||||
tracks.push({
|
||||
song_name: songName,
|
||||
artist: artist,
|
||||
album_name: albumName,
|
||||
timestamp: timestamp,
|
||||
ms_played: msPlayed
|
||||
});
|
||||
}
|
||||
|
||||
if (tracks.length === 0) {
|
||||
showMessage('Please fill in at least one complete row.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/scrobble', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tracks })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showMessage(`Successfully scrobbled ${result.count} track(s)!`, 'success');
|
||||
resetForm();
|
||||
} else {
|
||||
showMessage(result.error || 'Failed to scrobble tracks.', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showMessage('An error occurred. Please try again.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
function showMessage(text, type) {
|
||||
const msg = document.getElementById('message');
|
||||
msg.textContent = text;
|
||||
msg.className = 'message ' + type;
|
||||
msg.style.display = 'block';
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
const container = document.getElementById('rows-container');
|
||||
container.innerHTML = `
|
||||
<div class="scrobble-row">
|
||||
<input type="text" name="song_name" placeholder="Song Name" required>
|
||||
<input type="text" name="artist" placeholder="Artist" required>
|
||||
<input type="text" name="album_name" placeholder="Album (optional)">
|
||||
<input type="date" name="date" required value="${dateStr}">
|
||||
<input type="time" name="time" required step="1" value="${timeStr}">
|
||||
<input type="number" name="duration" placeholder="Duration (sec)" min="0">
|
||||
<button type="button" class="remove-row" style="display: none;">×</button>
|
||||
</div>
|
||||
`;
|
||||
updateRemoveButtons();
|
||||
}
|
||||
|
||||
updateRemoveButtons();
|
||||
</script>
|
||||
{{end}}
|
||||
142
templates/settings.gohtml
Normal file
@@ -0,0 +1,142 @@
|
||||
{{define "settings"}}
|
||||
<div class="settings-container">
|
||||
<h1>Settings</h1>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="settings-tabs">
|
||||
<button class="tab-button active" data-tab="import">Import Data</button>
|
||||
<button class="tab-button" data-tab="scrobble">Scrobble API</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content">
|
||||
<!-- Import Data Tab -->
|
||||
<div class="tab-panel active" id="import">
|
||||
<div class="import-section">
|
||||
<h2>Spotify</h2>
|
||||
<p>Import your Spotify listening history from your data export.</p>
|
||||
<form id="spotify-form" method="POST" action="/settings/import/spotify" enctype="multipart/form-data">
|
||||
<input type="file" name="json_files" accept=".json,application/json" multiple required>
|
||||
<button type="submit">Upload Spotify Data</button>
|
||||
</form>
|
||||
|
||||
<div id="spotify-progress" class="progress-container" style="display: none;">
|
||||
<div class="progress-status" id="spotify-progress-status">Initializing...</div>
|
||||
<div class="progress-bar-wrapper">
|
||||
<div class="progress-bar-fill" id="spotify-progress-fill"></div>
|
||||
<div class="progress-text" id="spotify-progress-text">0%</div>
|
||||
</div>
|
||||
<div class="progress-tracks" id="spotify-progress-tracks"></div>
|
||||
<div class="progress-error" id="spotify-progress-error"></div>
|
||||
<div class="progress-success" id="spotify-progress-success"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="import-section">
|
||||
<h2>Last.fm</h2>
|
||||
<p>Import your Last.fm scrobbles.</p>
|
||||
<form id="lastfm-form" method="POST" action="/settings/import/lastfm">
|
||||
<input type="text" name="lastfm_username" placeholder="Last.FM Username" required>
|
||||
<input type="text" name="lastfm_api_key" placeholder="Last.FM API Key" required>
|
||||
<button type="submit">Import from Last.fm</button>
|
||||
</form>
|
||||
|
||||
<div id="lastfm-progress" class="progress-container" style="display: none;">
|
||||
<div class="progress-status" id="lastfm-progress-status">Initializing...</div>
|
||||
<div class="progress-bar-wrapper">
|
||||
<div class="progress-bar-fill" id="lastfm-progress-fill"></div>
|
||||
<div class="progress-text" id="lastfm-progress-text">0%</div>
|
||||
</div>
|
||||
<div class="progress-tracks" id="lastfm-progress-tracks"></div>
|
||||
<div class="progress-error" id="lastfm-progress-error"></div>
|
||||
<div class="progress-success" id="lastfm-progress-success"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrobble API Tab -->
|
||||
<div class="tab-panel" id="scrobble">
|
||||
<div class="import-section">
|
||||
<h2>API Keys</h2>
|
||||
<p>Generate an API key to receive scrobbles from external apps.</p>
|
||||
{{if .APIKey}}
|
||||
<div class="api-key-display">
|
||||
<label>API Key:</label>
|
||||
<code>{{.APIKey}}</code>
|
||||
</div>
|
||||
<div class="api-key-display">
|
||||
<label>API Secret:</label>
|
||||
<code>{{.APISecret}}</code>
|
||||
</div>
|
||||
{{end}}
|
||||
<form method="POST" action="/settings/generate-apikey">
|
||||
<button type="submit">{{if .APIKey}}Regenerate{{else}}Generate{{end}} API Key</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="import-section">
|
||||
<h2>Endpoint URLs</h2>
|
||||
<p>Use these URLs in your scrobbling apps:</p>
|
||||
<div class="api-key-display">
|
||||
<label>Last.fm Compatible:</label>
|
||||
<code>/2.0/</code>
|
||||
</div>
|
||||
<div class="api-key-display">
|
||||
<label>Listenbrainz JSON:</label>
|
||||
<code>/1/submit-listens</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="import-section">
|
||||
<h2>Spotify Integration</h2>
|
||||
<p>Connect your Spotify account to automatically import your listening history.</p>
|
||||
<p>Create a Spotify app at <a href="https://developer.spotify.com/dashboard" target="_blank">developer.spotify.com</a> and enter your credentials below.</p>
|
||||
|
||||
<form method="POST" action="/settings/update-spotify">
|
||||
<input type="text" name="spotify_client_id" placeholder="Spotify Client ID" value="{{.SpotifyClientId}}">
|
||||
<input type="password" name="spotify_client_secret" placeholder="Spotify Client Secret">
|
||||
<button type="submit">Save Spotify Credentials</button>
|
||||
</form>
|
||||
|
||||
{{if and .SpotifyClientId (not .SpotifyConnected)}}
|
||||
<p><a href="/settings/spotify-connect" class="button">Connect Spotify</a></p>
|
||||
<p class="info">Click to authorize Muzi to access your Spotify account.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .SpotifyConnected}}
|
||||
<p class="success">Spotify is connected and importing!</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/files/import.js"></script>
|
||||
<script>
|
||||
function switchToTab(tabName, updateUrl = true) {
|
||||
document.querySelectorAll('.tab-button').forEach(b => b.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||
|
||||
document.querySelector(`.tab-button[data-tab="${tabName}"]`).classList.add('active');
|
||||
document.getElementById(tabName).classList.add('active');
|
||||
|
||||
if (updateUrl) {
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('tab', tabName);
|
||||
history.pushState({}, '', url);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab-button').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
switchToTab(button.dataset.tab);
|
||||
});
|
||||
});
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const tabParam = urlParams.get('tab');
|
||||
if (tabParam) {
|
||||
switchToTab(tabParam, false);
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
145
templates/song.gohtml
Normal file
@@ -0,0 +1,145 @@
|
||||
{{define "song"}}
|
||||
<div class="profile-top">
|
||||
<div class="username-bio">
|
||||
<h1>
|
||||
{{.Song.Title}}
|
||||
{{if eq .LoggedInUsername .Username}}
|
||||
<button class="edit-btn" onclick="openEditModal()">Edit</button>
|
||||
<button class="edit-btn" id="removeScrobblesBtn" onclick="toggleRemoveMode()">Remove</button>
|
||||
{{end}}
|
||||
</h1>
|
||||
{{if .ArtistNames}}
|
||||
<h2>
|
||||
{{- range $i, $name := .ArtistNames}}{{if $i}}, {{end}}<a href="/profile/{{$.Username}}/artist/{{urlquery $name}}">{{$name}}</a>{{end}}
|
||||
</h2>
|
||||
{{end}}
|
||||
{{range .Albums}}
|
||||
<h3><a href="/profile/{{$.Username}}/album/{{urlquery $.Artist.Name}}/{{urlquery .Title}}">{{.Title}}</a></h3>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="profile-top-blank">
|
||||
</div>
|
||||
<div class="user-stats-top">
|
||||
<h3>{{formatInt .ListenCount}}</h3> <p>Listens<p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<h3>Scrobbles</h3>
|
||||
<div id="removeControls" style="display: none;">
|
||||
<button class="edit-btn" onclick="cancelRemoveMode()">Cancel</button>
|
||||
<button class="edit-btn" onclick="deleteSelectedScrobbles()" style="background: #c44;">Delete Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<tr>
|
||||
<th class="remove-checkbox-col" style="display: none;"><input type="checkbox" id="selectAllCheckboxes"></th>
|
||||
<th>Artist</th>
|
||||
<th>Title</th>
|
||||
<th>Album</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
{{$username := .Username}}
|
||||
{{range .Times}}
|
||||
<tr>
|
||||
<td class="remove-checkbox-col" style="display: none;">
|
||||
<input type="checkbox" class="scrobble-checkbox" value="{{.Id}}">
|
||||
</td>
|
||||
<td>
|
||||
{{- $artistNames := getArtistNames .ArtistIds}}
|
||||
{{- range $i, $name := $artistNames}}{{if $i}}, {{end}}<a href="/profile/{{$username}}/artist/{{urlquery $name}}">{{$name}}</a>{{end}}
|
||||
</td>
|
||||
<td><a href="/profile/{{$username}}/song/{{urlquery .ArtistName}}/{{urlquery .SongName}}">{{.SongName}}</a></td>
|
||||
<td><a href="/profile/{{$username}}/album/{{urlquery .ArtistName}}/{{urlquery .AlbumName}}">{{.AlbumName}}</a></td>
|
||||
<td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
<div class="page_buttons">
|
||||
{{if gt .Page 1 }}
|
||||
<a href="/profile/{{.Username}}/song/{{urlquery .Artist.Name}}/{{urlquery .Song.Title}}?page={{sub .Page 1}}">Prev Page</a>
|
||||
{{end}}
|
||||
<a href="/profile/{{.Username}}/song/{{urlquery .Artist.Name}}/{{urlquery .Song.Title}}?page={{add .Page 1}}">Next Page</a>
|
||||
</div>
|
||||
|
||||
{{if eq .LoggedInUsername .Username}}
|
||||
<div id="editModal" class="modal-overlay" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<h2>Edit Song</h2>
|
||||
<form id="editForm" data-entity="song" data-id="{{.Song.Id}}">
|
||||
<label>Title: <input type="text" name="title" value="{{.Song.Title}}"></label>
|
||||
<label>Spotify ID: <input type="text" name="spotify_id" value="{{.Song.SpotifyId}}"></label>
|
||||
<label>MusicBrainz ID: <input type="text" name="musicbrainz_id" value="{{.Song.MusicbrainzId}}"></label>
|
||||
<div class="modal-buttons">
|
||||
<button type="button" class="cancel-btn" onclick="closeEditModal()">Cancel</button>
|
||||
<button type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<script>
|
||||
function toggleRemoveMode() {
|
||||
var checkboxes = document.querySelectorAll('.remove-checkbox-col');
|
||||
checkboxes.forEach(function(col) {
|
||||
col.style.display = '';
|
||||
});
|
||||
document.getElementById('removeScrobblesBtn').style.display = 'none';
|
||||
document.getElementById('removeControls').style.display = '';
|
||||
|
||||
var selectAll = document.getElementById('selectAllCheckboxes');
|
||||
selectAll.addEventListener('change', function() {
|
||||
document.querySelectorAll('.scrobble-checkbox').forEach(function(cb) {
|
||||
cb.checked = selectAll.checked;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function cancelRemoveMode() {
|
||||
var checkboxes = document.querySelectorAll('.remove-checkbox-col');
|
||||
checkboxes.forEach(function(col) {
|
||||
col.style.display = 'none';
|
||||
});
|
||||
document.getElementById('removeScrobblesBtn').style.display = '';
|
||||
document.getElementById('removeControls').style.display = 'none';
|
||||
|
||||
document.querySelectorAll('.scrobble-checkbox').forEach(function(cb) {
|
||||
cb.checked = false;
|
||||
});
|
||||
document.getElementById('selectAllCheckboxes').checked = false;
|
||||
}
|
||||
|
||||
function deleteSelectedScrobbles() {
|
||||
var checkboxes = document.querySelectorAll('.scrobble-checkbox:checked');
|
||||
var ids = [];
|
||||
checkboxes.forEach(function(cb) {
|
||||
ids.push(parseInt(cb.value));
|
||||
});
|
||||
|
||||
if (ids.length === 0) {
|
||||
alert('Please select at least one scrobble to delete.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Are you sure you want to delete ' + ids.length + ' scrobble(s)?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/scrobble/delete', true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4) {
|
||||
if (xhr.status === 200) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error deleting scrobbles: ' + xhr.responseText);
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.send(JSON.stringify(ids));
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
183
web/auth.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package web
|
||||
|
||||
// Functions used to authenticate web UI users.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"muzi/db"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Generates a hex string 32 characters in length.
|
||||
func generateID() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// Returns a salted hash of a password if valid (8-64 chars).
|
||||
func hashPassword(pass []byte) (string, error) {
|
||||
if len([]rune(string(pass))) < 8 || len(pass) > 64 {
|
||||
return "", errors.New("Error: Password must be greater than 8 chars.")
|
||||
}
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword(pass, bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Couldn't hash password: %v\n", err)
|
||||
return "", err
|
||||
}
|
||||
return string(hashedPassword), nil
|
||||
}
|
||||
|
||||
// Compares a plaintext password and a hashed password. Returns T/F depending
|
||||
// on comparison result.
|
||||
func verifyPassword(hashedPassword string, enteredPassword []byte) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), enteredPassword)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error while comparing passwords: %v\n", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Handles the submission of new account credentials. Stores credentials in
|
||||
// the users table. Sets a browser cookie for successful new users.
|
||||
func createAccount(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.FormValue("uname")
|
||||
if len([]rune(string(username))) == 0 {
|
||||
http.Redirect(w, r, "/createaccount?error=userlength", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
var usertaken bool
|
||||
err = db.Pool.QueryRow(r.Context(),
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)", username).
|
||||
Scan(&usertaken)
|
||||
if usertaken == true {
|
||||
http.Redirect(w, r, "/createaccount?error=usertaken", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
hashedPassword, err := hashPassword([]byte(r.FormValue("pass")))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error hashing password: %v\n", err)
|
||||
http.Redirect(w, r, "/createaccount?error=passlength", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.Pool.Exec(
|
||||
r.Context(),
|
||||
`INSERT INTO users (username, password) VALUES ($1, $2);`,
|
||||
username,
|
||||
hashedPassword,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot add new user to users table: %v\n", err)
|
||||
http.Redirect(w, r, "/createaccount", http.StatusSeeOther)
|
||||
} else {
|
||||
sessionID := createSession(username)
|
||||
if sessionID == "" {
|
||||
http.Redirect(w, r, "/login?error=session", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 86400 * 30,
|
||||
})
|
||||
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Renders the create account page
|
||||
func createAccountPageHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
type data struct {
|
||||
Error string
|
||||
}
|
||||
d := data{Error: "len"}
|
||||
err := templates.ExecuteTemplate(w, "create_account.gohtml", d)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handles submission of login credentials by checking if the username
|
||||
// is in the database and the stored password for that username matches the
|
||||
// given password. Sets browser cookie on successful login.
|
||||
func loginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.FormValue("uname")
|
||||
if username == "" {
|
||||
http.Redirect(w, r, "/login?error=invalid-creds", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
password := r.FormValue("pass")
|
||||
var storedPassword string
|
||||
err = db.Pool.QueryRow(r.Context(), "SELECT password FROM users WHERE username = $1;", username).
|
||||
Scan(&storedPassword)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot get password for entered username: %v\n", err)
|
||||
}
|
||||
|
||||
if verifyPassword(storedPassword, []byte(password)) {
|
||||
sessionID := createSession(username)
|
||||
if sessionID == "" {
|
||||
http.Redirect(w, r, "/login?error=session", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 86400 * 30,
|
||||
})
|
||||
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
|
||||
} else {
|
||||
http.Redirect(w, r, "/login?error=invalid-creds", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Renders the login page
|
||||
func loginPageHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
type data struct {
|
||||
Error string
|
||||
}
|
||||
d := data{Error: r.URL.Query().Get("error")}
|
||||
err := templates.ExecuteTemplate(w, "login.gohtml", d)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
1044
web/entity.go
Normal file
311
web/import.go
Normal file
@@ -0,0 +1,311 @@
|
||||
package web
|
||||
|
||||
// Functions that the web UI uses for importing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"muzi/migrate"
|
||||
)
|
||||
|
||||
// Global vars to hold active import jobs and a mutex to lock access to
|
||||
// importJobs
|
||||
var (
|
||||
importJobs = make(map[string]chan migrate.ProgressUpdate)
|
||||
jobsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// Renders the import page
|
||||
func importPageHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
username := getLoggedInUsername(r)
|
||||
if username == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
type ImportData struct {
|
||||
Username string
|
||||
}
|
||||
data := ImportData{Username: username}
|
||||
|
||||
err := templates.ExecuteTemplate(w, "import.gohtml", data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validates and parses tracks from uploaded Spotify JSON files
|
||||
func parseUploads(uploads []*multipart.FileHeader, w http.ResponseWriter) []migrate.SpotifyTrack {
|
||||
if len(uploads) < 1 {
|
||||
http.Error(w, "No files uploaded", http.StatusBadRequest)
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(uploads) > 30 {
|
||||
http.Error(w, "Too many files uploaded (30 max)", http.StatusBadRequest)
|
||||
return nil
|
||||
}
|
||||
|
||||
var allTracks []migrate.SpotifyTrack
|
||||
|
||||
for _, u := range uploads {
|
||||
if u.Size > maxHeaderSize {
|
||||
fmt.Fprintf(os.Stderr, "File too large: %s\n", u.Filename)
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(u.Filename, "..") ||
|
||||
strings.Contains(u.Filename, "/") ||
|
||||
strings.Contains(u.Filename, "\x00") {
|
||||
fmt.Fprintf(os.Stderr, "Invalid filename: %s\n", u.Filename)
|
||||
continue
|
||||
}
|
||||
|
||||
file, err := u.Open()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error opening %s: %v\n", u.Filename, err)
|
||||
continue
|
||||
}
|
||||
|
||||
reader := io.LimitReader(file, maxHeaderSize)
|
||||
data, err := io.ReadAll(reader)
|
||||
file.Close()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading %s: %v\n", u.Filename, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !json.Valid(data) {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON in %s", u.Filename),
|
||||
http.StatusBadRequest)
|
||||
return nil
|
||||
}
|
||||
|
||||
var tracks []migrate.SpotifyTrack
|
||||
if err := json.Unmarshal(data, &tracks); err != nil {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"Error parsing %s: %v\n", u.Filename, err)
|
||||
continue
|
||||
}
|
||||
|
||||
allTracks = append(allTracks, tracks...)
|
||||
}
|
||||
return allTracks
|
||||
}
|
||||
|
||||
// Imports the uploaded JSON files into the database
|
||||
func importSpotifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
username := getLoggedInUsername(r)
|
||||
if username == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
userId, err := getUserIdByUsername(r.Context(), username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot find user %s: %v\n", username, err)
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
err = r.ParseMultipartForm(32 * 1024 * 1024) // 32 MiB
|
||||
if err != nil {
|
||||
http.Error(w, "Error parsing form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
allTracks := parseUploads(r.MultipartForm.File["json_files"], w)
|
||||
if allTracks == nil {
|
||||
return
|
||||
}
|
||||
|
||||
jobID, err := generateID()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating jobID: %v\n", err)
|
||||
http.Error(w, "Error generating jobID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
progressChan := make(chan migrate.ProgressUpdate, 100)
|
||||
|
||||
jobsMu.Lock()
|
||||
importJobs[jobID] = progressChan
|
||||
jobsMu.Unlock()
|
||||
|
||||
go func() {
|
||||
migrate.ImportSpotify(allTracks, userId, progressChan)
|
||||
|
||||
jobsMu.Lock()
|
||||
delete(importJobs, jobID)
|
||||
jobsMu.Unlock()
|
||||
}()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"job_id": jobID,
|
||||
"status": "started",
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch a LastFM account's scrobbles and insert them into the database
|
||||
func importLastFMHandler(w http.ResponseWriter, r *http.Request) {
|
||||
username := getLoggedInUsername(r)
|
||||
if username == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
userId, err := getUserIdByUsername(r.Context(), username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot find user %s: %v\n", username, err)
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
err = r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
lastfmUsername := template.HTMLEscapeString(r.FormValue("lastfm_username"))
|
||||
lastfmAPIKey := template.HTMLEscapeString(r.FormValue("lastfm_api_key"))
|
||||
|
||||
if lastfmUsername == "" || lastfmAPIKey == "" {
|
||||
http.Error(w, "Missing required fields", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
jobID, err := generateID()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating jobID: %v\n", err)
|
||||
http.Error(w, "Error generating jobID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
progressChan := make(chan migrate.ProgressUpdate, 100)
|
||||
|
||||
jobsMu.Lock()
|
||||
importJobs[jobID] = progressChan
|
||||
jobsMu.Unlock()
|
||||
|
||||
go func() {
|
||||
migrate.ImportLastFM(lastfmUsername, lastfmAPIKey, userId, progressChan,
|
||||
username)
|
||||
|
||||
jobsMu.Lock()
|
||||
delete(importJobs, jobID)
|
||||
jobsMu.Unlock()
|
||||
}()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"job_id": jobID,
|
||||
"status": "started",
|
||||
})
|
||||
}
|
||||
|
||||
// Controls the progress bar for a LastFM import
|
||||
func importLastFMProgressHandler(w http.ResponseWriter, r *http.Request) {
|
||||
jobID := r.URL.Query().Get("job")
|
||||
if jobID == "" {
|
||||
http.Error(w, "Missing job ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
jobsMu.RLock()
|
||||
job, exists := importJobs[jobID]
|
||||
jobsMu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
http.Error(w, "Job not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "data: %s\n\n", `{"status":"connected"}`)
|
||||
flusher.Flush()
|
||||
|
||||
for update := range job {
|
||||
data, err := json.Marshal(update)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", string(data))
|
||||
flusher.Flush()
|
||||
|
||||
if update.Status == "completed" || update.Status == "error" {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Controls the progress bar for a Spotify import
|
||||
func importSpotifyProgressHandler(w http.ResponseWriter, r *http.Request) {
|
||||
jobID := r.URL.Query().Get("job")
|
||||
if jobID == "" {
|
||||
http.Error(w, "Missing job ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
jobsMu.RLock()
|
||||
job, exists := importJobs[jobID]
|
||||
jobsMu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
http.Error(w, "Job not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "data: %s\n\n", `{"status":"connected"}`)
|
||||
flusher.Flush()
|
||||
|
||||
for update := range job {
|
||||
data, err := json.Marshal(update)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", string(data))
|
||||
flusher.Flush()
|
||||
|
||||
if update.Status == "completed" || update.Status == "error" {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
340
web/profile.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package web
|
||||
|
||||
// Functions used for user profiles in the web UI
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"muzi/db"
|
||||
"muzi/scrobble"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgtype"
|
||||
)
|
||||
|
||||
type ProfileData struct {
|
||||
Username string
|
||||
Bio string
|
||||
Pfp string
|
||||
AllowDuplicateEdits bool
|
||||
ScrobbleCount int
|
||||
TrackCount int
|
||||
ArtistCount int
|
||||
Artists []string
|
||||
ArtistIdsList [][]int
|
||||
Titles []string
|
||||
Times []time.Time
|
||||
Page int
|
||||
Title string
|
||||
LoggedInUsername string
|
||||
TemplateName string
|
||||
NowPlayingArtist string
|
||||
NowPlayingTitle string
|
||||
TopArtists []db.TopArtist
|
||||
TopArtistsPeriod string
|
||||
TopArtistsLimit int
|
||||
TopArtistsView string
|
||||
TopAlbums []db.TopAlbum
|
||||
TopAlbumsPeriod string
|
||||
TopAlbumsLimit int
|
||||
TopAlbumsView string
|
||||
TopTracks []db.TopTrack
|
||||
TopTracksPeriod string
|
||||
TopTracksLimit int
|
||||
}
|
||||
|
||||
// Render a page of the profile in the URL
|
||||
func profilePageHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
username := chi.URLParam(r, "username")
|
||||
|
||||
userId, err := getUserIdByUsername(r.Context(), username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot find user %s: %v\n", username, err)
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
pageStr := r.URL.Query().Get("page")
|
||||
var pageInt int
|
||||
if pageStr == "" {
|
||||
pageInt = 1
|
||||
} else {
|
||||
pageInt, err = strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot convert page URL query from string to int: %v\n", err)
|
||||
pageInt = 1
|
||||
}
|
||||
}
|
||||
|
||||
lim := 15
|
||||
off := (pageInt - 1) * lim
|
||||
|
||||
var profileData ProfileData
|
||||
profileData.Username = username
|
||||
profileData.Page = pageInt
|
||||
profileData.Title = username + "'s Profile"
|
||||
profileData.LoggedInUsername = getLoggedInUsername(r)
|
||||
profileData.TemplateName = "profile"
|
||||
|
||||
err = db.Pool.QueryRow(
|
||||
r.Context(),
|
||||
`SELECT bio, pfp, allow_duplicate_edits,
|
||||
(SELECT COUNT(*) FROM history WHERE user_id = $1) as scrobble_count,
|
||||
(SELECT COUNT(*) FROM songs WHERE user_id = $1) as track_count,
|
||||
(SELECT COUNT(DISTINCT artist) FROM history WHERE user_id = $1) as artist_count
|
||||
FROM users WHERE pk = $1;`,
|
||||
userId,
|
||||
).Scan(&profileData.Bio, &profileData.Pfp, &profileData.AllowDuplicateEdits, &profileData.ScrobbleCount, &profileData.TrackCount, &profileData.ArtistCount)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot get profile for %s: %v\n", username, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
period := r.URL.Query().Get("period")
|
||||
if period == "" {
|
||||
period = "all_time"
|
||||
}
|
||||
|
||||
view := r.URL.Query().Get("view")
|
||||
if view == "" {
|
||||
view = "grid"
|
||||
}
|
||||
|
||||
maxLimit := 30
|
||||
if view == "grid" {
|
||||
maxLimit = 8
|
||||
}
|
||||
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 10
|
||||
if limitStr != "" {
|
||||
limit, err = strconv.Atoi(limitStr)
|
||||
if err != nil || limit < 5 {
|
||||
limit = 10
|
||||
}
|
||||
if limit > maxLimit {
|
||||
limit = maxLimit
|
||||
}
|
||||
}
|
||||
|
||||
profileData.TopArtistsPeriod = period
|
||||
profileData.TopArtistsLimit = limit
|
||||
profileData.TopArtistsView = view
|
||||
|
||||
var startDate, endDate *time.Time
|
||||
now := time.Now()
|
||||
switch period {
|
||||
case "week":
|
||||
start := now.AddDate(0, 0, -7)
|
||||
startDate = &start
|
||||
case "month":
|
||||
start := now.AddDate(0, -1, 0)
|
||||
startDate = &start
|
||||
case "year":
|
||||
start := now.AddDate(-1, 0, 0)
|
||||
startDate = &start
|
||||
case "custom":
|
||||
startStr := r.URL.Query().Get("start")
|
||||
endStr := r.URL.Query().Get("end")
|
||||
if startStr != "" {
|
||||
if t, err := time.Parse("2006-01-02", startStr); err == nil {
|
||||
startDate = &t
|
||||
}
|
||||
}
|
||||
if endStr != "" {
|
||||
if t, err := time.Parse("2006-01-02", endStr); err == nil {
|
||||
t = t.AddDate(0, 0, 1)
|
||||
endDate = &t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
topArtists, err := db.GetTopArtists(userId, limit, startDate, endDate)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot get top artists: %v\n", err)
|
||||
} else {
|
||||
profileData.TopArtists = topArtists
|
||||
}
|
||||
|
||||
albumPeriod := r.URL.Query().Get("album_period")
|
||||
if albumPeriod == "" {
|
||||
albumPeriod = "all_time"
|
||||
}
|
||||
|
||||
var albumStartDate, albumEndDate *time.Time
|
||||
albumNow := time.Now()
|
||||
switch albumPeriod {
|
||||
case "week":
|
||||
start := albumNow.AddDate(0, 0, -7)
|
||||
albumStartDate = &start
|
||||
case "month":
|
||||
start := albumNow.AddDate(0, -1, 0)
|
||||
albumStartDate = &start
|
||||
case "year":
|
||||
start := albumNow.AddDate(-1, 0, 0)
|
||||
albumStartDate = &start
|
||||
case "custom":
|
||||
albumStartStr := r.URL.Query().Get("album_start")
|
||||
albumEndStr := r.URL.Query().Get("album_end")
|
||||
if albumStartStr != "" {
|
||||
if t, err := time.Parse("2006-01-02", albumStartStr); err == nil {
|
||||
albumStartDate = &t
|
||||
}
|
||||
}
|
||||
if albumEndStr != "" {
|
||||
if t, err := time.Parse("2006-01-02", albumEndStr); err == nil {
|
||||
t = t.AddDate(0, 0, 1)
|
||||
albumEndDate = &t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
albumLimitStr := r.URL.Query().Get("album_limit")
|
||||
albumLimit := 10
|
||||
if albumLimitStr != "" {
|
||||
albumLimit, err = strconv.Atoi(albumLimitStr)
|
||||
if err != nil || albumLimit < 5 {
|
||||
albumLimit = 10
|
||||
}
|
||||
if albumLimit > 30 {
|
||||
albumLimit = 30
|
||||
}
|
||||
}
|
||||
|
||||
albumView := r.URL.Query().Get("album_view")
|
||||
if albumView == "" {
|
||||
albumView = "grid"
|
||||
}
|
||||
albumMaxLimit := 30
|
||||
if albumView == "grid" {
|
||||
albumMaxLimit = 8
|
||||
}
|
||||
if albumLimit > albumMaxLimit {
|
||||
albumLimit = albumMaxLimit
|
||||
}
|
||||
|
||||
profileData.TopAlbumsPeriod = albumPeriod
|
||||
profileData.TopAlbumsLimit = albumLimit
|
||||
profileData.TopAlbumsView = albumView
|
||||
|
||||
topAlbums, err := db.GetTopAlbums(userId, albumLimit, albumStartDate, albumEndDate)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot get top albums: %v\n", err)
|
||||
} else {
|
||||
profileData.TopAlbums = topAlbums
|
||||
}
|
||||
|
||||
trackPeriod := r.URL.Query().Get("track_period")
|
||||
if trackPeriod == "" {
|
||||
trackPeriod = "all_time"
|
||||
}
|
||||
|
||||
var trackStartDate, trackEndDate *time.Time
|
||||
trackNow := time.Now()
|
||||
switch trackPeriod {
|
||||
case "week":
|
||||
start := trackNow.AddDate(0, 0, -7)
|
||||
trackStartDate = &start
|
||||
case "month":
|
||||
start := trackNow.AddDate(0, -1, 0)
|
||||
trackStartDate = &start
|
||||
case "year":
|
||||
start := trackNow.AddDate(-1, 0, 0)
|
||||
trackStartDate = &start
|
||||
case "custom":
|
||||
trackStartStr := r.URL.Query().Get("track_start")
|
||||
trackEndStr := r.URL.Query().Get("track_end")
|
||||
if trackStartStr != "" {
|
||||
if t, err := time.Parse("2006-01-02", trackStartStr); err == nil {
|
||||
trackStartDate = &t
|
||||
}
|
||||
}
|
||||
if trackEndStr != "" {
|
||||
if t, err := time.Parse("2006-01-02", trackEndStr); err == nil {
|
||||
t = t.AddDate(0, 0, 1)
|
||||
trackEndDate = &t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trackLimitStr := r.URL.Query().Get("track_limit")
|
||||
trackLimit := 10
|
||||
if trackLimitStr != "" {
|
||||
trackLimit, err = strconv.Atoi(trackLimitStr)
|
||||
if err != nil || trackLimit < 5 {
|
||||
trackLimit = 10
|
||||
}
|
||||
if trackLimit > 30 {
|
||||
trackLimit = 30
|
||||
}
|
||||
}
|
||||
|
||||
profileData.TopTracksPeriod = trackPeriod
|
||||
profileData.TopTracksLimit = trackLimit
|
||||
|
||||
topTracks, err := db.GetTopTracks(userId, trackLimit, trackStartDate, trackEndDate)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot get top tracks: %v\n", err)
|
||||
} else {
|
||||
profileData.TopTracks = topTracks
|
||||
}
|
||||
|
||||
if pageInt == 1 {
|
||||
if np, ok := scrobble.GetNowPlaying(userId); ok {
|
||||
profileData.NowPlayingArtist = np.Artist
|
||||
profileData.NowPlayingTitle = np.SongName
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := db.Pool.Query(
|
||||
r.Context(),
|
||||
"SELECT artist_id, song_name, timestamp, artist_ids FROM history WHERE user_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3;",
|
||||
userId,
|
||||
lim,
|
||||
off,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "SELECT history failed: %v\n", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var artistId int
|
||||
var title string
|
||||
var time pgtype.Timestamptz
|
||||
var artistIds []int
|
||||
err = rows.Scan(&artistId, &title, &time, &artistIds)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Scanning history row failed: %v\n", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var artistName string
|
||||
if artistId > 0 {
|
||||
artist, err := db.GetArtistById(artistId)
|
||||
if err == nil {
|
||||
artistName = artist.Name
|
||||
}
|
||||
}
|
||||
|
||||
profileData.Artists = append(profileData.Artists, artistName)
|
||||
profileData.ArtistIdsList = append(profileData.ArtistIdsList, artistIds)
|
||||
profileData.Titles = append(profileData.Titles, title)
|
||||
profileData.Times = append(profileData.Times, time.Time)
|
||||
}
|
||||
|
||||
err = templates.ExecuteTemplate(w, "base", profileData)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
183
web/scrobble.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"muzi/db"
|
||||
)
|
||||
|
||||
type ScrobbleTrack struct {
|
||||
SongName string `json:"song_name"`
|
||||
Artist string `json:"artist"`
|
||||
AlbumName string `json:"album_name"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
MsPlayed int `json:"ms_played"`
|
||||
}
|
||||
|
||||
type ScrobbleRequest struct {
|
||||
Tracks []ScrobbleTrack `json:"tracks"`
|
||||
}
|
||||
|
||||
type scrobbleData struct {
|
||||
Title string
|
||||
LoggedInUsername string
|
||||
TemplateName string
|
||||
}
|
||||
|
||||
func scrobblePageHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
username := getLoggedInUsername(r)
|
||||
if username == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
data := scrobbleData{
|
||||
Title: "muzi | Manual Scrobble",
|
||||
LoggedInUsername: username,
|
||||
TemplateName: "scrobble",
|
||||
}
|
||||
|
||||
err := templates.ExecuteTemplate(w, "base", data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scrobbleSubmitHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
username := getLoggedInUsername(r)
|
||||
if username == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
userId, err := getUserIdByUsername(r.Context(), username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot find user %s: %v\n", username, err)
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var req ScrobbleRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Tracks) == 0 {
|
||||
http.Error(w, "No tracks provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := insertScrobbles(r.Context(), userId, req.Tracks)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error inserting scrobbles: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("Error inserting scrobbles: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"count": count,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func insertScrobbles(ctx context.Context, userId int, tracks []ScrobbleTrack) (int, error) {
|
||||
artistIdMap := make(map[string][]int)
|
||||
|
||||
for _, track := range tracks {
|
||||
if track.Artist == "" || track.SongName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
artistNames := parseArtistString(track.Artist)
|
||||
var artistIds []int
|
||||
for _, name := range artistNames {
|
||||
artistId, _, err := db.GetOrCreateArtist(userId, name)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating artist %s: %v\n", name, err)
|
||||
continue
|
||||
}
|
||||
artistIds = append(artistIds, artistId)
|
||||
}
|
||||
artistIdMap[track.Artist+"|"+track.SongName] = artistIds
|
||||
}
|
||||
|
||||
imported := 0
|
||||
for _, track := range tracks {
|
||||
if track.Artist == "" || track.SongName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
timestamp, err := time.Parse(time.RFC3339, track.Timestamp)
|
||||
if err != nil {
|
||||
timestamp = time.Now()
|
||||
}
|
||||
|
||||
artistIds := artistIdMap[track.Artist+"|"+track.SongName]
|
||||
var artistId int
|
||||
if len(artistIds) > 0 {
|
||||
artistId = artistIds[0]
|
||||
}
|
||||
|
||||
var albumId int
|
||||
if track.AlbumName != "" && artistId > 0 {
|
||||
albumId, _, _ = db.GetOrCreateAlbum(userId, track.AlbumName, artistId)
|
||||
}
|
||||
|
||||
var songId int
|
||||
if track.SongName != "" && artistId > 0 {
|
||||
songId, _, _ = db.GetOrCreateSong(userId, track.SongName, artistId, albumId)
|
||||
}
|
||||
|
||||
var albumNamePg *string
|
||||
if track.AlbumName != "" {
|
||||
albumNamePg = &track.AlbumName
|
||||
}
|
||||
|
||||
_, err = db.Pool.Exec(ctx,
|
||||
`INSERT INTO history (user_id, timestamp, song_name, artist, album_name, ms_played, platform, artist_id, song_id, artist_ids)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (user_id, song_name, artist, timestamp) DO NOTHING`,
|
||||
userId, timestamp, track.SongName, track.Artist, albumNamePg, track.MsPlayed, "manual", artistId, songId, artistIds,
|
||||
)
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "duplicate") {
|
||||
fmt.Fprintf(os.Stderr, "Error inserting scrobble: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
}
|
||||
|
||||
return imported, nil
|
||||
}
|
||||
|
||||
func parseArtistString(artist string) []string {
|
||||
if artist == "" {
|
||||
return nil
|
||||
}
|
||||
var artists []string
|
||||
for _, a := range strings.Split(artist, ",") {
|
||||
a = strings.TrimSpace(a)
|
||||
if a != "" {
|
||||
artists = append(artists, a)
|
||||
}
|
||||
}
|
||||
return artists
|
||||
}
|
||||
94
web/session.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package web
|
||||
|
||||
// Functions that handle browser login sessions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"muzi/db"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
Username string
|
||||
}
|
||||
|
||||
func createSession(username string) string {
|
||||
sessionID, err := generateID()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating sessionID: %v\n", err)
|
||||
return ""
|
||||
}
|
||||
_, err = db.Pool.Exec(
|
||||
context.Background(),
|
||||
"INSERT INTO sessions (session_id, username, expires_at) VALUES ($1, $2, NOW() + INTERVAL '30 days');",
|
||||
sessionID,
|
||||
username,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating session: %v\n", err)
|
||||
return ""
|
||||
}
|
||||
return sessionID
|
||||
}
|
||||
|
||||
func getSession(ctx context.Context, sessionID string) *Session {
|
||||
var username string
|
||||
err := db.Pool.QueryRow(
|
||||
ctx,
|
||||
"SELECT username FROM sessions WHERE session_id = $1 AND expires_at > NOW();",
|
||||
sessionID,
|
||||
).Scan(&username)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &Session{Username: username}
|
||||
}
|
||||
|
||||
func deleteSession(sessionID string) {
|
||||
_, err := db.Pool.Exec(
|
||||
context.Background(),
|
||||
"DELETE FROM sessions WHERE session_id = $1;",
|
||||
sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error deleting session: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getLoggedInUsername(r *http.Request) string {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
session := getSession(r.Context(), cookie.Value)
|
||||
if session == nil {
|
||||
return ""
|
||||
}
|
||||
return session.Username
|
||||
}
|
||||
|
||||
func getUserIdByUsername(ctx context.Context, username string) (int, error) {
|
||||
var userId int
|
||||
err := db.Pool.QueryRow(ctx, "SELECT pk FROM users WHERE username = $1;", username).
|
||||
Scan(&userId)
|
||||
return userId, err
|
||||
}
|
||||
|
||||
func logoutHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err == nil {
|
||||
deleteSession(cookie.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
167
web/settings.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"muzi/scrobble"
|
||||
)
|
||||
|
||||
type settingsData struct {
|
||||
Title string
|
||||
LoggedInUsername string
|
||||
TemplateName string
|
||||
APIKey string
|
||||
APISecret string
|
||||
SpotifyClientId string
|
||||
SpotifyConnected bool
|
||||
}
|
||||
|
||||
func settingsPageHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
username := getLoggedInUsername(r)
|
||||
if username == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
userId, err := getUserIdByUsername(r.Context(), username)
|
||||
if err != nil {
|
||||
http.Error(w, "User not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := scrobble.GetUserById(userId)
|
||||
if err != nil {
|
||||
http.Error(w, "Error loading user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
d := settingsData{
|
||||
Title: "muzi | Settings",
|
||||
LoggedInUsername: username,
|
||||
TemplateName: "settings",
|
||||
APIKey: "",
|
||||
APISecret: "",
|
||||
SpotifyClientId: "",
|
||||
SpotifyConnected: user.IsSpotifyConnected(),
|
||||
}
|
||||
|
||||
if user.ApiKey != nil {
|
||||
d.APIKey = *user.ApiKey
|
||||
}
|
||||
if user.ApiSecret != nil {
|
||||
d.APISecret = *user.ApiSecret
|
||||
}
|
||||
if user.SpotifyClientId != nil {
|
||||
d.SpotifyClientId = *user.SpotifyClientId
|
||||
}
|
||||
|
||||
err = templates.ExecuteTemplate(w, "base", d)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generateAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
username := getLoggedInUsername(r)
|
||||
if username == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
userId, err := getUserIdByUsername(r.Context(), username)
|
||||
if err != nil {
|
||||
http.Error(w, "User not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, err := scrobble.GenerateAPIKey()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating API key: %v\n", err)
|
||||
http.Error(w, "Error generating API key", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
apiSecret, err := scrobble.GenerateAPISecret()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating API secret: %v\n", err)
|
||||
http.Error(w, "Error generating API secret", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = scrobble.UpdateUserAPIKey(userId, apiKey, apiSecret)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error saving API key: %v\n", err)
|
||||
http.Error(w, "Error saving API key", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/settings?tab=scrobble", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func updateSpotifyCredentialsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
username := getLoggedInUsername(r)
|
||||
if username == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
userId, err := getUserIdByUsername(r.Context(), username)
|
||||
if err != nil {
|
||||
http.Error(w, "User not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
clientId := r.FormValue("spotify_client_id")
|
||||
clientSecret := r.FormValue("spotify_client_secret")
|
||||
|
||||
if clientId == "" || clientSecret == "" {
|
||||
err = scrobble.DeleteUserSpotifyCredentials(userId)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error removing Spotify credentials: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
err = scrobble.UpdateUserSpotifyCredentials(userId, clientId, clientSecret)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error saving Spotify credentials: %v\n", err)
|
||||
http.Error(w, "Error saving Spotify credentials", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/settings?tab=scrobble", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func spotifyConnectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
username := getLoggedInUsername(r)
|
||||
if username == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
userId, err := getUserIdByUsername(r.Context(), username)
|
||||
if err != nil {
|
||||
http.Error(w, "User not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := scrobble.GetUserById(userId)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "spotifyConnectHandler: GetUserById error: %v\n", err)
|
||||
http.Redirect(w, r, "/settings?tab=scrobble", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "spotifyConnectHandler: SpotifyClientId is nil or empty, redirecting to settings\n")
|
||||
|
||||
if user.SpotifyClientId == nil || *user.SpotifyClientId == "" {
|
||||
fmt.Fprintf(os.Stderr, "spotifyConnectHandler: SpotifyClientId is nil or empty, redirecting to settings\n")
|
||||
http.Redirect(w, r, "/settings?tab=scrobble", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/scrobble/spotify/authorize?user_id=%d", userId), http.StatusSeeOther)
|
||||
}
|
||||
138
web/utils.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package web
|
||||
|
||||
// Functions used in the HTML templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"muzi/db"
|
||||
)
|
||||
|
||||
// Subtracts two integers
|
||||
func sub(a int, b int) int {
|
||||
return a - b
|
||||
}
|
||||
|
||||
// Adds two integers
|
||||
func add(a int, b int) int {
|
||||
return a + b
|
||||
}
|
||||
|
||||
// Divides two integers (integer division)
|
||||
func div(a int, b int) int {
|
||||
if b == 0 {
|
||||
return 0
|
||||
}
|
||||
return a / b
|
||||
}
|
||||
|
||||
// Returns a % b
|
||||
func mod(a int, b int) int {
|
||||
return a % b
|
||||
}
|
||||
|
||||
// Returns a slice of a slice from start to end
|
||||
func slice(a []db.TopArtist, start int, end int) []db.TopArtist {
|
||||
if start >= len(a) {
|
||||
return []db.TopArtist{}
|
||||
}
|
||||
if end > len(a) {
|
||||
end = len(a)
|
||||
}
|
||||
return a[start:end]
|
||||
}
|
||||
|
||||
func sliceAlbum(a []db.TopAlbum, start int, end int) []db.TopAlbum {
|
||||
if start >= len(a) {
|
||||
return []db.TopAlbum{}
|
||||
}
|
||||
if end > len(a) {
|
||||
end = len(a)
|
||||
}
|
||||
return a[start:end]
|
||||
}
|
||||
|
||||
func sliceTrack(a []db.TopTrack, start int, end int) []db.TopTrack {
|
||||
if start >= len(a) {
|
||||
return []db.TopTrack{}
|
||||
}
|
||||
if end > len(a) {
|
||||
end = len(a)
|
||||
}
|
||||
return a[start:end]
|
||||
}
|
||||
|
||||
func gridReorder(artists []db.TopArtist) []db.TopArtist {
|
||||
if len(artists) < 2 {
|
||||
return artists
|
||||
}
|
||||
if len(artists)%2 == 0 {
|
||||
return artists
|
||||
}
|
||||
remaining := len(artists) - 1
|
||||
perRow := remaining / 2
|
||||
rest := artists[1:]
|
||||
firstRow := rest[:perRow]
|
||||
secondRow := rest[perRow:]
|
||||
var reordered []db.TopArtist
|
||||
reordered = append(reordered, artists[0])
|
||||
reordered = append(reordered, secondRow...)
|
||||
reordered = append(reordered, firstRow...)
|
||||
return reordered
|
||||
}
|
||||
|
||||
// Put a comma in the thousands place, ten-thousands place etc.
|
||||
func formatInt(n int) string {
|
||||
if n < 1000 {
|
||||
return fmt.Sprintf("%d", n)
|
||||
} else {
|
||||
return formatInt(n/1000) + "," + fmt.Sprintf("%03d", n%1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Formats timestamps compared to local time
|
||||
func formatTimestamp(timestamp time.Time) string {
|
||||
now := time.Now()
|
||||
duration := now.Sub(timestamp)
|
||||
|
||||
if duration < 24*time.Hour {
|
||||
seconds := int(duration.Seconds())
|
||||
if seconds < 60 {
|
||||
return fmt.Sprintf("%d seconds ago", seconds)
|
||||
}
|
||||
minutes := seconds / 60
|
||||
if minutes < 60 {
|
||||
return fmt.Sprintf("%d minutes ago", minutes)
|
||||
}
|
||||
hours := minutes / 60
|
||||
return fmt.Sprintf("%d hours ago", hours)
|
||||
}
|
||||
|
||||
year := now.Year()
|
||||
if timestamp.Year() == year {
|
||||
return timestamp.Format("2 Jan 3:04pm")
|
||||
}
|
||||
|
||||
return timestamp.Format("2 Jan 2006 3:04pm")
|
||||
}
|
||||
|
||||
// Full timestamp format for browser hover
|
||||
func formatTimestampFull(timestamp time.Time) string {
|
||||
return timestamp.Format("Monday 2 Jan 2006, 3:04pm")
|
||||
}
|
||||
|
||||
// GetArtistNames takes artist IDs and returns a slice of artist names
|
||||
func GetArtistNames(artistIds []int) []string {
|
||||
if artistIds == nil {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
for _, id := range artistIds {
|
||||
artist, err := db.GetArtistById(id)
|
||||
if err == nil {
|
||||
names = append(names, artist.Name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
444
web/web.go
@@ -1,387 +1,151 @@
|
||||
package web
|
||||
|
||||
// Main web UI controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"muzi/config"
|
||||
"muzi/db"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"muzi/scrobble"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/jackc/pgtype"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type ProfileData struct {
|
||||
Username string
|
||||
Bio string
|
||||
Pfp string
|
||||
AllowDuplicateEdits bool
|
||||
ScrobbleCount int
|
||||
ArtistCount int
|
||||
Artists []string
|
||||
Titles []string
|
||||
Times []string
|
||||
Page int
|
||||
// 50 MiB
|
||||
const maxHeaderSize int64 = 50 * 1024 * 1024
|
||||
|
||||
func serverAddrStr() string {
|
||||
return config.Get().Server.Address
|
||||
}
|
||||
|
||||
func Sub(a int, b int) int {
|
||||
return a - b
|
||||
// Holds all the parsed HTML templates
|
||||
var templates *template.Template
|
||||
|
||||
// Declares all functions for the HTML templates and parses them
|
||||
func init() {
|
||||
funcMap := template.FuncMap{
|
||||
"sub": sub,
|
||||
"add": add,
|
||||
"div": div,
|
||||
"mod": mod,
|
||||
"slice": slice,
|
||||
"sliceAlbum": sliceAlbum,
|
||||
"sliceTrack": sliceTrack,
|
||||
"gridReorder": gridReorder,
|
||||
"formatInt": formatInt,
|
||||
"formatTimestamp": formatTimestamp,
|
||||
"formatTimestampFull": formatTimestampFull,
|
||||
"urlquery": url.QueryEscape,
|
||||
"getArtistNames": GetArtistNames,
|
||||
}
|
||||
templates = template.Must(template.New("").Funcs(funcMap).ParseGlob("./templates/*.gohtml"))
|
||||
}
|
||||
|
||||
func Add(a int, b int) int {
|
||||
return a + b
|
||||
}
|
||||
|
||||
func getUserIdByUsername(conn *pgx.Conn, username string) (int, error) {
|
||||
var userId int
|
||||
err := conn.QueryRow(context.Background(), "SELECT pk FROM users WHERE username = $1;", username).
|
||||
Scan(&userId)
|
||||
return userId, err
|
||||
}
|
||||
|
||||
func getTimes(conn *pgx.Conn, userId int, lim int, off int) []string {
|
||||
var times []string
|
||||
rows, err := conn.Query(
|
||||
context.Background(),
|
||||
"SELECT timestamp FROM history WHERE user_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3;",
|
||||
userId,
|
||||
lim,
|
||||
off,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "SELECT timestamp failed: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
for rows.Next() {
|
||||
var time pgtype.Timestamptz
|
||||
err = rows.Scan(&time)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Scanning time failed: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
times = append(times, time.Time.String())
|
||||
}
|
||||
return times
|
||||
}
|
||||
|
||||
func getTitles(conn *pgx.Conn, userId int, lim int, off int) []string {
|
||||
var titles []string
|
||||
rows, err := conn.Query(
|
||||
context.Background(),
|
||||
"SELECT song_name FROM history WHERE user_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3;",
|
||||
userId,
|
||||
lim,
|
||||
off,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "SELECT song_name failed: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
for rows.Next() {
|
||||
var title string
|
||||
err = rows.Scan(&title)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Scanning title failed: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
titles = append(titles, title)
|
||||
}
|
||||
return titles
|
||||
}
|
||||
|
||||
func getArtists(conn *pgx.Conn, userId int, lim int, off int) []string {
|
||||
var artists []string
|
||||
rows, err := conn.Query(
|
||||
context.Background(),
|
||||
"SELECT artist FROM history WHERE user_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3;",
|
||||
userId,
|
||||
lim,
|
||||
off,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "SELECT artist failed: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
for rows.Next() {
|
||||
var artist string
|
||||
err = rows.Scan(&artist)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Scanning artist name failed: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
artists = append(artists, artist)
|
||||
}
|
||||
return artists
|
||||
}
|
||||
|
||||
func getScrobbles(conn *pgx.Conn, userId int) int {
|
||||
// Returns T/F if a user is found in the users table
|
||||
func hasUsers(ctx context.Context) bool {
|
||||
var count int
|
||||
err := conn.QueryRow(context.Background(), "SELECT COUNT(*) FROM history WHERE user_id = $1;", userId).
|
||||
Scan(&count)
|
||||
err := db.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM users;").Scan(&count)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "SELECT COUNT failed: %v\n", err)
|
||||
return 0
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func getArtistCount(conn *pgx.Conn, userId int) int {
|
||||
var count int
|
||||
err := conn.QueryRow(context.Background(), "SELECT COUNT(DISTINCT artist) FROM history WHERE user_id = $1;", userId).
|
||||
Scan(&count)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "SELECT artist count failed: %v\n", err)
|
||||
return 0
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func hashPassword(pass []byte) string {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword(pass, bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Couldn't hash password: %v\n", err)
|
||||
}
|
||||
return string(hashedPassword)
|
||||
}
|
||||
|
||||
func verifyPassword(hashedPassword string, enteredPassword []byte) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), enteredPassword)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error while comparing passwords: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Error checking for users: %v\n", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func createAccount(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := pgx.Connect(
|
||||
context.Background(),
|
||||
"postgres://postgres:postgres@localhost:5432/muzi",
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot connect to muzi database: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close(context.Background())
|
||||
|
||||
if r.Method == "POST" {
|
||||
r.ParseForm()
|
||||
|
||||
username := r.FormValue("uname")
|
||||
hashedPassword := hashPassword([]byte(r.FormValue("pass")))
|
||||
|
||||
err = db.CreateUsersTable(conn)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error ensuring users table exists: %v\n", err)
|
||||
// Controls what is displayed at the root URL.
|
||||
// If logged in: Logged in user's profile page.
|
||||
// If logged out: Login page.
|
||||
// If no users in DB yet: Create account page.
|
||||
func rootHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !hasUsers(r.Context()) {
|
||||
http.Redirect(w, r, "/createaccount", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = conn.Exec(
|
||||
context.Background(),
|
||||
`INSERT INTO users (username, password) VALUES ($1, $2);`,
|
||||
username,
|
||||
hashedPassword,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot add new user to users table: %v\n", err)
|
||||
http.Redirect(w, r, "/createaccount", http.StatusSeeOther)
|
||||
} else {
|
||||
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createAccountPageHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tmp, err := template.New("create_account.gohtml").
|
||||
ParseFiles("./templates/create_account.gohtml")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = tmp.Execute(w, nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := pgx.Connect(
|
||||
context.Background(),
|
||||
"postgres://postgres:postgres@localhost:5432/muzi",
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot connect to muzi database: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close(context.Background())
|
||||
|
||||
if r.Method == "POST" {
|
||||
r.ParseForm()
|
||||
|
||||
username := r.FormValue("uname")
|
||||
password := r.FormValue("pass")
|
||||
var storedPassword string
|
||||
err := conn.QueryRow(context.Background(), "SELECT password FROM users WHERE username = $1;", username).
|
||||
Scan(&storedPassword)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot get password for entered username: %v\n", err)
|
||||
}
|
||||
|
||||
if verifyPassword(storedPassword, []byte(password)) {
|
||||
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
|
||||
} else {
|
||||
http.Redirect(w, r, "/login?error=1", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loginPageHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
type data struct {
|
||||
ShowError bool
|
||||
}
|
||||
d := data{ShowError: false}
|
||||
if r.URL.Query().Get("error") != "" {
|
||||
d.ShowError = true
|
||||
}
|
||||
tmp, err := template.New("login.gohtml").ParseFiles("./templates/login.gohtml")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
username := getLoggedInUsername(r)
|
||||
if username == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
err = tmp.Execute(w, d)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func profilePageHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
username := chi.URLParam(r, "username")
|
||||
|
||||
conn, err := pgx.Connect(
|
||||
context.Background(),
|
||||
"postgres://postgres:postgres@localhost:5432/muzi",
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot connect to muzi database: %v\n", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer conn.Close(context.Background())
|
||||
|
||||
userId, err := getUserIdByUsername(conn, username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot find user %s: %v\n", username, err)
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
pageStr := r.URL.Query().Get("page")
|
||||
var pageInt int
|
||||
if pageStr == "" {
|
||||
pageInt = 1
|
||||
} else {
|
||||
pageInt, err = strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot convert page URL query from string to int: %v\n", err)
|
||||
pageInt = 1
|
||||
}
|
||||
}
|
||||
|
||||
lim := 15
|
||||
off := (pageInt - 1) * lim
|
||||
|
||||
var profileData ProfileData
|
||||
|
||||
err = conn.QueryRow(
|
||||
context.Background(),
|
||||
"SELECT bio, pfp, allow_duplicate_edits FROM users WHERE pk = $1;",
|
||||
userId,
|
||||
).Scan(&profileData.Bio, &profileData.Pfp, &profileData.AllowDuplicateEdits)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot get profile for %s: %v\n", username, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
profileData.Username = username
|
||||
profileData.ScrobbleCount = getScrobbles(conn, userId)
|
||||
profileData.ArtistCount = getArtistCount(conn, userId)
|
||||
profileData.Artists = getArtists(conn, userId, lim, off)
|
||||
profileData.Titles = getTitles(conn, userId, lim, off)
|
||||
profileData.Times = getTimes(conn, userId, lim, off)
|
||||
profileData.Page = pageInt
|
||||
|
||||
funcMap := template.FuncMap{
|
||||
"Sub": Sub,
|
||||
"Add": Add,
|
||||
}
|
||||
|
||||
tmp, err := template.New("profile.gohtml").
|
||||
Funcs(funcMap).
|
||||
ParseFiles("./templates/profile.gohtml")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tmp.Execute(w, profileData)
|
||||
}
|
||||
}
|
||||
|
||||
func updateDuplicateEditsSetting(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := pgx.Connect(
|
||||
context.Background(),
|
||||
"postgres://postgres:postgres@localhost:5432/muzi",
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot connect to muzi database: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close(context.Background())
|
||||
|
||||
if r.Method == "POST" {
|
||||
r.ParseForm()
|
||||
username := r.FormValue("username")
|
||||
allow := r.FormValue("allow") == "true"
|
||||
|
||||
_, err = conn.Exec(
|
||||
context.Background(),
|
||||
`UPDATE users SET allow_duplicate_edits = $1 WHERE username = $2;`,
|
||||
allow,
|
||||
username,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error updating setting: %v\n", err)
|
||||
}
|
||||
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// Serves all pages at the specified address.
|
||||
func Start() {
|
||||
addr := ":1234"
|
||||
addr := config.Get().Server.Address
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Logger)
|
||||
r.Handle("/files/*", http.StripPrefix("/files", http.FileServer(http.Dir("./static"))))
|
||||
r.Get("/", rootHandler())
|
||||
r.Get("/login", loginPageHandler())
|
||||
r.Get("/logout", logoutHandler())
|
||||
r.Get("/createaccount", createAccountPageHandler())
|
||||
r.Get("/profile/{username}", profilePageHandler())
|
||||
r.Get("/profile/{username}/artist/{artist}", artistPageHandler())
|
||||
r.Get("/profile/{username}/song/{artist}/{song}", songPageHandler())
|
||||
r.Get("/profile/{username}/album/{artist}/{album}", albumPageHandler())
|
||||
r.Get("/profile/{username}/album/{album}", func(w http.ResponseWriter, r *http.Request) {
|
||||
username := chi.URLParam(r, "username")
|
||||
albumTitle, _ := url.QueryUnescape(chi.URLParam(r, "album"))
|
||||
userId, err := getUserIdByUsername(r.Context(), username)
|
||||
if err != nil {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
albums, _, _ := db.SearchAlbums(userId, albumTitle)
|
||||
if len(albums) > 0 {
|
||||
album := albums[0]
|
||||
artist, _ := db.GetArtistById(album.ArtistId)
|
||||
http.Redirect(w, r, "/profile/"+username+"/album/"+url.QueryEscape(artist.Name)+"/"+url.QueryEscape(album.Title), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Album not found", http.StatusNotFound)
|
||||
})
|
||||
r.Post("/profile/{username}/artist/{id}/edit", editArtistHandler())
|
||||
r.Post("/profile/{username}/song/{id}/edit", editSongHandler())
|
||||
r.Post("/profile/{username}/album/{id}/edit", editAlbumHandler())
|
||||
r.Patch("/api/artist/{id}/edit", artistInlineEditHandler())
|
||||
r.Patch("/api/song/{id}/edit", songInlineEditHandler())
|
||||
r.Patch("/api/album/{id}/edit", albumInlineEditHandler())
|
||||
r.Patch("/api/artist/{id}/batch", artistBatchEditHandler())
|
||||
r.Patch("/api/song/{id}/batch", songBatchEditHandler())
|
||||
r.Patch("/api/album/{id}/batch", albumBatchEditHandler())
|
||||
r.Post("/api/scrobble/delete", deleteScrobbleHandler())
|
||||
r.Post("/api/upload/image", imageUploadHandler())
|
||||
r.Get("/search", searchHandler())
|
||||
r.Get("/import", importPageHandler())
|
||||
r.Post("/loginsubmit", loginSubmit)
|
||||
r.Post("/createaccountsubmit", createAccount)
|
||||
r.Post("/settings/duplicate-edits", updateDuplicateEditsSetting)
|
||||
r.Post("/import/lastfm", importLastFMHandler)
|
||||
r.Post("/import/spotify", importSpotifyHandler)
|
||||
r.Get("/import/lastfm/progress", importLastFMProgressHandler)
|
||||
r.Get("/import/spotify/progress", importSpotifyProgressHandler)
|
||||
r.Get("/scrobble", scrobblePageHandler())
|
||||
r.Post("/scrobble", scrobbleSubmitHandler())
|
||||
|
||||
r.Handle("/2.0", scrobble.NewLastFMHandler())
|
||||
r.Handle("/2.0/", scrobble.NewLastFMHandler())
|
||||
r.Post("/1/submit-listens", http.HandlerFunc(scrobble.NewListenbrainzHandler().ServeHTTP))
|
||||
r.Route("/scrobble/spotify", func(r chi.Router) {
|
||||
r.Get("/authorize", http.HandlerFunc(scrobble.NewSpotifyHandler().ServeHTTP))
|
||||
r.Get("/callback", http.HandlerFunc(scrobble.NewSpotifyHandler().ServeHTTP))
|
||||
})
|
||||
|
||||
r.Get("/settings/spotify-connect", spotifyConnectHandler)
|
||||
r.Get("/settings", settingsPageHandler())
|
||||
r.Post("/settings/generate-apikey", generateAPIKeyHandler)
|
||||
r.Post("/settings/update-spotify", updateSpotifyCredentialsHandler)
|
||||
fmt.Printf("WebUI starting on %s\n", addr)
|
||||
http.ListenAndServe(addr, r)
|
||||
prot := http.NewCrossOriginProtection()
|
||||
http.ListenAndServe(addr, prot.Handler(r))
|
||||
}
|
||||
|
||||