+2
-2
.air.toml
+2
-2
.air.toml
···
14
14
follow_symlink = false
15
15
full_bin = ""
16
16
include_dir = []
17
-
include_ext = ["go", "tpl", "tmpl", "html"]
17
+
include_ext = ["go", "tpl", "tmpl", "html", "gohtml", "css", "js"]
18
18
include_file = []
19
19
kill_delay = "0s"
20
20
log = "build-errors.log"
···
48
48
proxy_port = 0
49
49
50
50
[screen]
51
-
clear_on_rebuild = false
51
+
clear_on_rebuild = true
52
52
keep_scroll = true
+2
.env.template
+2
.env.template
+21
-4
Dockerfile
+21
-4
Dockerfile
···
1
-
FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:latest as builder
1
+
FROM --platform=${BUILDPLATFORM:-linux/amd64} node:24-alpine3.21 as node_builder
2
+
WORKDIR /app
3
+
RUN npm install tailwindcss @tailwindcss/cli
4
+
5
+
COPY ./pages/templates /app/templates
6
+
COPY ./pages/static /app/static
7
+
8
+
RUN npx @tailwindcss/cli -i /app/static/base.css -o /app/static/main.css -m
9
+
10
+
FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:1.24.3-alpine3.21 as builder
2
11
3
12
ARG TARGETPLATFORM
4
13
ARG BUILDPLATFORM
5
14
ARG TARGETOS
6
15
ARG TARGETARCH
7
16
17
+
#needed for sqlite
18
+
RUN apk add --update gcc musl-dev
19
+
8
20
# step 1. dep cache
9
21
WORKDIR /app
10
22
ARG TARGETPLATFORM=${BUILDPLATFORM:-linux/amd64}
···
14
26
# step 2. build the actual app
15
27
WORKDIR /app
16
28
COPY . .
17
-
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-w -s" -o main ./cmd
29
+
#Overwrite the main.css with the one from the builder
30
+
COPY --from=node_builder /app/static/main.css /app/pages/static/main.css
31
+
#generate the jwks
32
+
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags='-w -s -extldflags "-static"' -o main ./cmd
18
33
ARG TARGETOS=${TARGETPLATFORM%%/*}
19
34
ARG TARGETARCH=${TARGETPLATFORM##*/}
20
35
21
-
FROM --platform=${TARGETPLATFORM:-linux/amd64} scratch
22
-
WORKDIR /app/
36
+
FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine:3.21
37
+
#Creates an empty /db folder for docker compose
38
+
WORKDIR /db
39
+
WORKDIR /app
23
40
COPY --from=builder /app/main /app/main
24
41
ENTRYPOINT ["/app/main"]
-4
Makefile
-4
Makefile
+68
-4
README.md
+68
-4
README.md
···
9
9
10
10
well its just a work in progress... we build in the open!
11
11
12
-
#### development
12
+
## setup
13
+
It is recommend to have port forward url while working with piper. Development or running from docker because of external callbacks.
14
+
15
+
You have a couple of options
16
+
1. Setup the traditional port forward on your router
17
+
2. Use a tool like [ngrok](https://ngrok.com/) with the command `ngrok http 8080` or [Cloudflare tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-remote-tunnel/) (follow the 2a. portion of the guide when you get to that point)
18
+
19
+
Either way make note of what the publicly accessible domain name is for setting up env variables. It will be something like `https://piper.teal.fm` that you can access publicly
20
+
21
+
#### env variables
22
+
Copy [.env.template](.env.template) and name it [.env](.env)
23
+
24
+
This is a break down of what each env variable is and what it may look like
25
+
26
+
**_breaking piper/v0.0.2 changes env_**
27
+
28
+
You now have to bring your own private key to run piper. Can do this via goat `goat key generate -t P-256`. You want the one that is labeled under "Secret Key (Multibase Syntax): save this securely (eg, add to password manager)"
29
+
30
+
- `ATPROTO_CLIENT_SECRET_KEY` - Private key for oauth confidential client. This can be generated via goat `goat key generate -t P-256`
31
+
- `ATPROTO_CLIENT_SECRET_KEY_ID` - Key ID for oauth confidential client. This needs to be persistent and unique, can use a timestamp. Here's one for you: `1758199756`
32
+
33
+
34
+
- `SERVER_PORT` - The port piper is hosted on
35
+
- `SERVER_HOST` - The server host. `localhost` is fine here, or `0.0.0.0` for docker
36
+
- `SERVER_ROOT_URL` - This needs to be the pubically accessible url created in [Setup](#setup). Like `https://piper.teal.fm`
37
+
- `SPOTIFY_CLIENT_ID` - Client Id from setup in [Spotify developer dashboard](https://developer.spotify.com/documentation/web-api/tutorials/getting-started)
38
+
- `SPOTIFY_CLIENT_SECRET` - Client Secret from setup in [Spotify developer dashboard](https://developer.spotify.com/documentation/web-api/tutorials/getting-started)
39
+
- `SPOTIFY_AUTH_URL` - most likely `https://accounts.spotify.com/authorize`
40
+
- `SPOTIFY_TOKEN_URL` - most likely `https://accounts.spotify.com/api/token`
41
+
- `SPOTIFY_SCOPES` - most likely `user-read-currently-playing user-read-email`
42
+
- `CALLBACK_SPOTIFY` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/callback/spotify`
43
+
44
+
- `ATPROTO_CLIENT_ID` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/.well-known/client-metadata.json`
45
+
- `ATPROTO_METADATA_URL` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/.well-known/client-metadata.json`
46
+
- `ATPROTO_CALLBACK_URL` - The first part is your publicly accessible domain. So will something like this `https://piper.teal.fm/callback/atproto`
47
+
48
+
- `LASTFM_API_KEY` - Your lastfm api key. Can find out how to setup [here](https://www.last.fm/api)
49
+
50
+
- `TRACKER_INTERVAL` - How long between checks to see if the registered users are listening to new music
51
+
- `DB_PATH`= Path for the sqlite db. If you are using the docker compose probably want `/db/piper.db` to persist data
52
+
53
+
54
+
55
+
## development
56
+
57
+
make sure you have your env setup following [the env var setup](#env-variables)
13
58
14
59
assuming you have go installed and set up properly:
15
60
16
61
run some make scripts:
17
62
18
63
```
19
-
make jwtgen
20
64
21
65
make dev-setup
22
66
```
···
32
76
```
33
77
air
34
78
```
35
-
36
79
air should automatically build and run piper, and watch for changes on relevant files.
37
80
81
+
82
+
## tailwindcss
83
+
84
+
To use tailwindcss you will have to install the tailwindcss cli. This will take the [./pages/static/base.css](./pages/static/base.css) and transform it into a [./pages/static/main.css](./pages/static/main.css)
85
+
which is imported on the [./pages/templates/layouts/base.gohtml](./pages/templates/layouts/base.gohtml). When running the dev server tailwindcss will watch for changes and recompile the main.css file.
86
+
87
+
1. Install tailwindcss cli `npm install tailwindcss @tailwindcss/cli`
88
+
2. run `npx @tailwindcss/cli -i ./pages/static/base.css -o ./pages/static/main.css --watch`
89
+
90
+
91
+
92
+
93
+
#### Lexicon changes
94
+
1. Copy the new or changed json schema files to the [lexicon folders](./lexicons)
95
+
2. run `make go-lexicons`
96
+
97
+
Go types should be updated and should have the changes to the schemas
98
+
38
99
#### docker
100
+
We also provide a docker compose file to use to run piper locally. There are a few edits to the [.env](.env) to make it run smoother in a container
101
+
`SERVER_HOST`- `0.0.0.0`
102
+
`DB_PATH` = `/db/piper.db` to persist your piper db through container restarts
39
103
40
-
TODO
104
+
Make sure you have docker and docker compose installed, then you can run piper with `docker compose up`
+346
-177
api/teal/cbor_gen.go
+346
-177
api/teal/cbor_gen.go
···
27
27
}
28
28
29
29
cw := cbg.NewCborWriter(w)
30
-
fieldCount := 14
30
+
fieldCount := 15
31
31
32
32
if t.ArtistMbIds == nil {
33
+
fieldCount--
34
+
}
35
+
36
+
if t.ArtistNames == nil {
37
+
fieldCount--
38
+
}
39
+
40
+
if t.Artists == nil {
33
41
fieldCount--
34
42
}
35
43
···
126
134
}
127
135
if _, err := cw.WriteString(string("fm.teal.alpha.feed.play")); err != nil {
128
136
return err
137
+
}
138
+
139
+
// t.Artists ([]*teal.AlphaFeedDefs_Artist) (slice)
140
+
if t.Artists != nil {
141
+
142
+
if len("artists") > 1000000 {
143
+
return xerrors.Errorf("Value in field \"artists\" was too long")
144
+
}
145
+
146
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("artists"))); err != nil {
147
+
return err
148
+
}
149
+
if _, err := cw.WriteString(string("artists")); err != nil {
150
+
return err
151
+
}
152
+
153
+
if len(t.Artists) > 8192 {
154
+
return xerrors.Errorf("Slice value in field t.Artists was too long")
155
+
}
156
+
157
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Artists))); err != nil {
158
+
return err
159
+
}
160
+
for _, v := range t.Artists {
161
+
if err := v.MarshalCBOR(cw); err != nil {
162
+
return err
163
+
}
164
+
165
+
}
129
166
}
130
167
131
168
// t.Duration (int64) (int64)
···
316
353
}
317
354
318
355
// t.ArtistNames ([]string) (slice)
319
-
if len("artistNames") > 1000000 {
320
-
return xerrors.Errorf("Value in field \"artistNames\" was too long")
321
-
}
356
+
if t.ArtistNames != nil {
322
357
323
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("artistNames"))); err != nil {
324
-
return err
325
-
}
326
-
if _, err := cw.WriteString(string("artistNames")); err != nil {
327
-
return err
328
-
}
358
+
if len("artistNames") > 1000000 {
359
+
return xerrors.Errorf("Value in field \"artistNames\" was too long")
360
+
}
329
361
330
-
if len(t.ArtistNames) > 8192 {
331
-
return xerrors.Errorf("Slice value in field t.ArtistNames was too long")
332
-
}
362
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("artistNames"))); err != nil {
363
+
return err
364
+
}
365
+
if _, err := cw.WriteString(string("artistNames")); err != nil {
366
+
return err
367
+
}
333
368
334
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.ArtistNames))); err != nil {
335
-
return err
336
-
}
337
-
for _, v := range t.ArtistNames {
338
-
if len(v) > 1000000 {
339
-
return xerrors.Errorf("Value in field v was too long")
369
+
if len(t.ArtistNames) > 8192 {
370
+
return xerrors.Errorf("Slice value in field t.ArtistNames was too long")
340
371
}
341
372
342
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
373
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.ArtistNames))); err != nil {
343
374
return err
344
375
}
345
-
if _, err := cw.WriteString(string(v)); err != nil {
346
-
return err
347
-
}
376
+
for _, v := range t.ArtistNames {
377
+
if len(v) > 1000000 {
378
+
return xerrors.Errorf("Value in field v was too long")
379
+
}
348
380
381
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
382
+
return err
383
+
}
384
+
if _, err := cw.WriteString(string(v)); err != nil {
385
+
return err
386
+
}
387
+
388
+
}
349
389
}
350
390
351
391
// t.ReleaseMbId (string) (string)
···
582
622
}
583
623
584
624
t.LexiconTypeID = string(sval)
625
+
}
626
+
// t.Artists ([]*teal.AlphaFeedDefs_Artist) (slice)
627
+
case "artists":
628
+
629
+
maj, extra, err = cr.ReadHeader()
630
+
if err != nil {
631
+
return err
632
+
}
633
+
634
+
if extra > 8192 {
635
+
return fmt.Errorf("t.Artists: array too large (%d)", extra)
636
+
}
637
+
638
+
if maj != cbg.MajArray {
639
+
return fmt.Errorf("expected cbor array")
640
+
}
641
+
642
+
if extra > 0 {
643
+
t.Artists = make([]*AlphaFeedDefs_Artist, extra)
644
+
}
645
+
646
+
for i := 0; i < int(extra); i++ {
647
+
{
648
+
var maj byte
649
+
var extra uint64
650
+
var err error
651
+
_ = maj
652
+
_ = extra
653
+
_ = err
654
+
655
+
{
656
+
657
+
b, err := cr.ReadByte()
658
+
if err != nil {
659
+
return err
660
+
}
661
+
if b != cbg.CborNull[0] {
662
+
if err := cr.UnreadByte(); err != nil {
663
+
return err
664
+
}
665
+
t.Artists[i] = new(AlphaFeedDefs_Artist)
666
+
if err := t.Artists[i].UnmarshalCBOR(cr); err != nil {
667
+
return xerrors.Errorf("unmarshaling t.Artists[i] pointer: %w", err)
668
+
}
669
+
}
670
+
671
+
}
672
+
673
+
}
585
674
}
586
675
// t.Duration (int64) (int64)
587
676
case "duration":
···
1733
1822
}
1734
1823
1735
1824
cw := cbg.NewCborWriter(w)
1736
-
fieldCount := 13
1737
-
1738
-
if t.ArtistMbIds == nil {
1739
-
fieldCount--
1740
-
}
1825
+
fieldCount := 12
1741
1826
1742
1827
if t.Duration == nil {
1743
1828
fieldCount--
···
1813
1898
return err
1814
1899
}
1815
1900
}
1901
+
}
1902
+
1903
+
// t.Artists ([]*teal.AlphaFeedDefs_Artist) (slice)
1904
+
if len("artists") > 1000000 {
1905
+
return xerrors.Errorf("Value in field \"artists\" was too long")
1906
+
}
1907
+
1908
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("artists"))); err != nil {
1909
+
return err
1910
+
}
1911
+
if _, err := cw.WriteString(string("artists")); err != nil {
1912
+
return err
1913
+
}
1914
+
1915
+
if len(t.Artists) > 8192 {
1916
+
return xerrors.Errorf("Slice value in field t.Artists was too long")
1917
+
}
1918
+
1919
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Artists))); err != nil {
1920
+
return err
1921
+
}
1922
+
for _, v := range t.Artists {
1923
+
if err := v.MarshalCBOR(cw); err != nil {
1924
+
return err
1925
+
}
1926
+
1816
1927
}
1817
1928
1818
1929
// t.Duration (int64) (int64)
···
1966
2077
}
1967
2078
}
1968
2079
1969
-
// t.ArtistMbIds ([]string) (slice)
1970
-
if t.ArtistMbIds != nil {
1971
-
1972
-
if len("artistMbIds") > 1000000 {
1973
-
return xerrors.Errorf("Value in field \"artistMbIds\" was too long")
1974
-
}
1975
-
1976
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("artistMbIds"))); err != nil {
1977
-
return err
1978
-
}
1979
-
if _, err := cw.WriteString(string("artistMbIds")); err != nil {
1980
-
return err
1981
-
}
1982
-
1983
-
if len(t.ArtistMbIds) > 8192 {
1984
-
return xerrors.Errorf("Slice value in field t.ArtistMbIds was too long")
1985
-
}
1986
-
1987
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.ArtistMbIds))); err != nil {
1988
-
return err
1989
-
}
1990
-
for _, v := range t.ArtistMbIds {
1991
-
if len(v) > 1000000 {
1992
-
return xerrors.Errorf("Value in field v was too long")
1993
-
}
1994
-
1995
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
1996
-
return err
1997
-
}
1998
-
if _, err := cw.WriteString(string(v)); err != nil {
1999
-
return err
2000
-
}
2001
-
2002
-
}
2003
-
}
2004
-
2005
-
// t.ArtistNames ([]string) (slice)
2006
-
if len("artistNames") > 1000000 {
2007
-
return xerrors.Errorf("Value in field \"artistNames\" was too long")
2008
-
}
2009
-
2010
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("artistNames"))); err != nil {
2011
-
return err
2012
-
}
2013
-
if _, err := cw.WriteString(string("artistNames")); err != nil {
2014
-
return err
2015
-
}
2016
-
2017
-
if len(t.ArtistNames) > 8192 {
2018
-
return xerrors.Errorf("Slice value in field t.ArtistNames was too long")
2019
-
}
2020
-
2021
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.ArtistNames))); err != nil {
2022
-
return err
2023
-
}
2024
-
for _, v := range t.ArtistNames {
2025
-
if len(v) > 1000000 {
2026
-
return xerrors.Errorf("Value in field v was too long")
2027
-
}
2028
-
2029
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
2030
-
return err
2031
-
}
2032
-
if _, err := cw.WriteString(string(v)); err != nil {
2033
-
return err
2034
-
}
2035
-
2036
-
}
2037
-
2038
2080
// t.ReleaseMbId (string) (string)
2039
2081
if t.ReleaseMbId != nil {
2040
2082
···
2259
2301
t.Isrc = (*string)(&sval)
2260
2302
}
2261
2303
}
2304
+
// t.Artists ([]*teal.AlphaFeedDefs_Artist) (slice)
2305
+
case "artists":
2306
+
2307
+
maj, extra, err = cr.ReadHeader()
2308
+
if err != nil {
2309
+
return err
2310
+
}
2311
+
2312
+
if extra > 8192 {
2313
+
return fmt.Errorf("t.Artists: array too large (%d)", extra)
2314
+
}
2315
+
2316
+
if maj != cbg.MajArray {
2317
+
return fmt.Errorf("expected cbor array")
2318
+
}
2319
+
2320
+
if extra > 0 {
2321
+
t.Artists = make([]*AlphaFeedDefs_Artist, extra)
2322
+
}
2323
+
2324
+
for i := 0; i < int(extra); i++ {
2325
+
{
2326
+
var maj byte
2327
+
var extra uint64
2328
+
var err error
2329
+
_ = maj
2330
+
_ = extra
2331
+
_ = err
2332
+
2333
+
{
2334
+
2335
+
b, err := cr.ReadByte()
2336
+
if err != nil {
2337
+
return err
2338
+
}
2339
+
if b != cbg.CborNull[0] {
2340
+
if err := cr.UnreadByte(); err != nil {
2341
+
return err
2342
+
}
2343
+
t.Artists[i] = new(AlphaFeedDefs_Artist)
2344
+
if err := t.Artists[i].UnmarshalCBOR(cr); err != nil {
2345
+
return xerrors.Errorf("unmarshaling t.Artists[i] pointer: %w", err)
2346
+
}
2347
+
}
2348
+
2349
+
}
2350
+
2351
+
}
2352
+
}
2262
2353
// t.Duration (int64) (int64)
2263
2354
case "duration":
2264
2355
{
···
2369
2460
t.PlayedTime = (*string)(&sval)
2370
2461
}
2371
2462
}
2372
-
// t.ArtistMbIds ([]string) (slice)
2373
-
case "artistMbIds":
2374
-
2375
-
maj, extra, err = cr.ReadHeader()
2376
-
if err != nil {
2377
-
return err
2378
-
}
2379
-
2380
-
if extra > 8192 {
2381
-
return fmt.Errorf("t.ArtistMbIds: array too large (%d)", extra)
2382
-
}
2383
-
2384
-
if maj != cbg.MajArray {
2385
-
return fmt.Errorf("expected cbor array")
2386
-
}
2387
-
2388
-
if extra > 0 {
2389
-
t.ArtistMbIds = make([]string, extra)
2390
-
}
2391
-
2392
-
for i := 0; i < int(extra); i++ {
2393
-
{
2394
-
var maj byte
2395
-
var extra uint64
2396
-
var err error
2397
-
_ = maj
2398
-
_ = extra
2399
-
_ = err
2400
-
2401
-
{
2402
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2403
-
if err != nil {
2404
-
return err
2405
-
}
2406
-
2407
-
t.ArtistMbIds[i] = string(sval)
2408
-
}
2409
-
2410
-
}
2411
-
}
2412
-
// t.ArtistNames ([]string) (slice)
2413
-
case "artistNames":
2414
-
2415
-
maj, extra, err = cr.ReadHeader()
2416
-
if err != nil {
2417
-
return err
2418
-
}
2419
-
2420
-
if extra > 8192 {
2421
-
return fmt.Errorf("t.ArtistNames: array too large (%d)", extra)
2422
-
}
2423
-
2424
-
if maj != cbg.MajArray {
2425
-
return fmt.Errorf("expected cbor array")
2426
-
}
2427
-
2428
-
if extra > 0 {
2429
-
t.ArtistNames = make([]string, extra)
2430
-
}
2431
-
2432
-
for i := 0; i < int(extra); i++ {
2433
-
{
2434
-
var maj byte
2435
-
var extra uint64
2436
-
var err error
2437
-
_ = maj
2438
-
_ = extra
2439
-
_ = err
2440
-
2441
-
{
2442
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2443
-
if err != nil {
2444
-
return err
2445
-
}
2446
-
2447
-
t.ArtistNames[i] = string(sval)
2448
-
}
2449
-
2450
-
}
2451
-
}
2452
2463
// t.ReleaseMbId (string) (string)
2453
2464
case "releaseMbId":
2454
2465
···
2565
2576
2566
2577
return nil
2567
2578
}
2579
+
func (t *AlphaFeedDefs_Artist) MarshalCBOR(w io.Writer) error {
2580
+
if t == nil {
2581
+
_, err := w.Write(cbg.CborNull)
2582
+
return err
2583
+
}
2584
+
2585
+
cw := cbg.NewCborWriter(w)
2586
+
fieldCount := 2
2587
+
2588
+
if t.ArtistMbId == nil {
2589
+
fieldCount--
2590
+
}
2591
+
2592
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
2593
+
return err
2594
+
}
2595
+
2596
+
// t.ArtistMbId (string) (string)
2597
+
if t.ArtistMbId != nil {
2598
+
2599
+
if len("artistMbId") > 1000000 {
2600
+
return xerrors.Errorf("Value in field \"artistMbId\" was too long")
2601
+
}
2602
+
2603
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("artistMbId"))); err != nil {
2604
+
return err
2605
+
}
2606
+
if _, err := cw.WriteString(string("artistMbId")); err != nil {
2607
+
return err
2608
+
}
2609
+
2610
+
if t.ArtistMbId == nil {
2611
+
if _, err := cw.Write(cbg.CborNull); err != nil {
2612
+
return err
2613
+
}
2614
+
} else {
2615
+
if len(*t.ArtistMbId) > 1000000 {
2616
+
return xerrors.Errorf("Value in field t.ArtistMbId was too long")
2617
+
}
2618
+
2619
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ArtistMbId))); err != nil {
2620
+
return err
2621
+
}
2622
+
if _, err := cw.WriteString(string(*t.ArtistMbId)); err != nil {
2623
+
return err
2624
+
}
2625
+
}
2626
+
}
2627
+
2628
+
// t.ArtistName (string) (string)
2629
+
if len("artistName") > 1000000 {
2630
+
return xerrors.Errorf("Value in field \"artistName\" was too long")
2631
+
}
2632
+
2633
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("artistName"))); err != nil {
2634
+
return err
2635
+
}
2636
+
if _, err := cw.WriteString(string("artistName")); err != nil {
2637
+
return err
2638
+
}
2639
+
2640
+
if len(t.ArtistName) > 1000000 {
2641
+
return xerrors.Errorf("Value in field t.ArtistName was too long")
2642
+
}
2643
+
2644
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.ArtistName))); err != nil {
2645
+
return err
2646
+
}
2647
+
if _, err := cw.WriteString(string(t.ArtistName)); err != nil {
2648
+
return err
2649
+
}
2650
+
return nil
2651
+
}
2652
+
2653
+
func (t *AlphaFeedDefs_Artist) UnmarshalCBOR(r io.Reader) (err error) {
2654
+
*t = AlphaFeedDefs_Artist{}
2655
+
2656
+
cr := cbg.NewCborReader(r)
2657
+
2658
+
maj, extra, err := cr.ReadHeader()
2659
+
if err != nil {
2660
+
return err
2661
+
}
2662
+
defer func() {
2663
+
if err == io.EOF {
2664
+
err = io.ErrUnexpectedEOF
2665
+
}
2666
+
}()
2667
+
2668
+
if maj != cbg.MajMap {
2669
+
return fmt.Errorf("cbor input should be of type map")
2670
+
}
2671
+
2672
+
if extra > cbg.MaxLength {
2673
+
return fmt.Errorf("AlphaFeedDefs_Artist: map struct too large (%d)", extra)
2674
+
}
2675
+
2676
+
n := extra
2677
+
2678
+
nameBuf := make([]byte, 10)
2679
+
for i := uint64(0); i < n; i++ {
2680
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
2681
+
if err != nil {
2682
+
return err
2683
+
}
2684
+
2685
+
if !ok {
2686
+
// Field doesn't exist on this type, so ignore it
2687
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
2688
+
return err
2689
+
}
2690
+
continue
2691
+
}
2692
+
2693
+
switch string(nameBuf[:nameLen]) {
2694
+
// t.ArtistMbId (string) (string)
2695
+
case "artistMbId":
2696
+
2697
+
{
2698
+
b, err := cr.ReadByte()
2699
+
if err != nil {
2700
+
return err
2701
+
}
2702
+
if b != cbg.CborNull[0] {
2703
+
if err := cr.UnreadByte(); err != nil {
2704
+
return err
2705
+
}
2706
+
2707
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2708
+
if err != nil {
2709
+
return err
2710
+
}
2711
+
2712
+
t.ArtistMbId = (*string)(&sval)
2713
+
}
2714
+
}
2715
+
// t.ArtistName (string) (string)
2716
+
case "artistName":
2717
+
2718
+
{
2719
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2720
+
if err != nil {
2721
+
return err
2722
+
}
2723
+
2724
+
t.ArtistName = string(sval)
2725
+
}
2726
+
2727
+
default:
2728
+
// Field doesn't exist on this type, so ignore it
2729
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
2730
+
return err
2731
+
}
2732
+
}
2733
+
}
2734
+
2735
+
return nil
2736
+
}
+10
-4
api/teal/feeddefs.go
+10
-4
api/teal/feeddefs.go
···
4
4
5
5
// schema: fm.teal.alpha.feed.defs
6
6
7
+
// AlphaFeedDefs_Artist is a "artist" in the fm.teal.alpha.feed.defs schema.
8
+
type AlphaFeedDefs_Artist struct {
9
+
// artistMbId: The Musicbrainz ID of the artist
10
+
ArtistMbId *string `json:"artistMbId,omitempty" cborgen:"artistMbId,omitempty"`
11
+
// artistName: The name of the artist
12
+
ArtistName string `json:"artistName" cborgen:"artistName"`
13
+
}
14
+
7
15
// AlphaFeedDefs_PlayView is a "playView" in the fm.teal.alpha.feed.defs schema.
8
16
type AlphaFeedDefs_PlayView struct {
9
-
// artistMbIds: Array of Musicbrainz artist IDs
10
-
ArtistMbIds []string `json:"artistMbIds,omitempty" cborgen:"artistMbIds,omitempty"`
11
-
// artistNames: Array of artist names in order of original appearance.
12
-
ArtistNames []string `json:"artistNames" cborgen:"artistNames"`
17
+
// artists: Array of artists in order of original appearance.
18
+
Artists []*AlphaFeedDefs_Artist `json:"artists" cborgen:"artists"`
13
19
// duration: The length of the track in seconds
14
20
Duration *int64 `json:"duration,omitempty" cborgen:"duration,omitempty"`
15
21
// isrc: The ISRC code associated with the recording
+5
-3
api/teal/feedplay.go
+5
-3
api/teal/feedplay.go
···
14
14
// RECORDTYPE: AlphaFeedPlay
15
15
type AlphaFeedPlay struct {
16
16
LexiconTypeID string `json:"$type,const=fm.teal.alpha.feed.play" cborgen:"$type,const=fm.teal.alpha.feed.play"`
17
-
// artistMbIds: Array of Musicbrainz artist IDs
17
+
// artistMbIds: Array of Musicbrainz artist IDs. Prefer using 'artists'.
18
18
ArtistMbIds []string `json:"artistMbIds,omitempty" cborgen:"artistMbIds,omitempty"`
19
-
// artistNames: Array of artist names in order of original appearance.
20
-
ArtistNames []string `json:"artistNames" cborgen:"artistNames"`
19
+
// artistNames: Array of artist names in order of original appearance. Prefer using 'artists'.
20
+
ArtistNames []string `json:"artistNames,omitempty" cborgen:"artistNames,omitempty"`
21
+
// artists: Array of artists in order of original appearance.
22
+
Artists []*AlphaFeedDefs_Artist `json:"artists,omitempty" cborgen:"artists,omitempty"`
21
23
// duration: The length of the track in seconds
22
24
Duration *int64 `json:"duration,omitempty" cborgen:"duration,omitempty"`
23
25
// isrc: The ISRC code associated with the recording
+170
-132
cmd/handlers.go
+170
-132
cmd/handlers.go
···
8
8
"strconv"
9
9
10
10
"github.com/teal-fm/piper/db"
11
+
"github.com/teal-fm/piper/models"
12
+
atprotoauth "github.com/teal-fm/piper/oauth/atproto"
13
+
pages "github.com/teal-fm/piper/pages"
14
+
atprotoservice "github.com/teal-fm/piper/service/atproto"
11
15
"github.com/teal-fm/piper/service/musicbrainz"
16
+
"github.com/teal-fm/piper/service/playingnow"
12
17
"github.com/teal-fm/piper/service/spotify"
13
18
"github.com/teal-fm/piper/session"
14
19
)
15
20
16
-
func home(database *db.DB) http.HandlerFunc {
21
+
type HomeParams struct {
22
+
NavBar pages.NavBar
23
+
}
24
+
25
+
func home(database *db.DB, pg *pages.Pages) http.HandlerFunc {
17
26
return func(w http.ResponseWriter, r *http.Request) {
18
27
19
28
w.Header().Set("Content-Type", "text/html")
···
31
40
log.Printf("Error fetching user %d details for home page: %v", userID, err)
32
41
}
33
42
}
34
-
35
-
html := `
36
-
<html>
37
-
<head>
38
-
<title>Piper - Spotify & Last.fm Tracker</title>
39
-
<style>
40
-
body {
41
-
font-family: Arial, sans-serif;
42
-
max-width: 800px;
43
-
margin: 0 auto;
44
-
padding: 20px;
45
-
line-height: 1.6;
46
-
}
47
-
h1 {
48
-
color: #1DB954; /* Spotify green */
49
-
}
50
-
.nav {
51
-
display: flex;
52
-
flex-wrap: wrap; /* Allow wrapping on smaller screens */
53
-
margin-bottom: 20px;
54
-
}
55
-
.nav a {
56
-
margin-right: 15px;
57
-
margin-bottom: 5px; /* Add spacing below links */
58
-
text-decoration: none;
59
-
color: #1DB954;
60
-
font-weight: bold;
61
-
}
62
-
.card {
63
-
border: 1px solid #ddd;
64
-
border-radius: 8px;
65
-
padding: 20px;
66
-
margin-bottom: 20px;
67
-
}
68
-
.service-status {
69
-
font-style: italic;
70
-
color: #555;
71
-
}
72
-
</style>
73
-
</head>
74
-
<body>
75
-
<h1>Piper - Multi-User Spotify & Last.fm Tracker via ATProto</h1>
76
-
<div class="nav">
77
-
<a href="/">Home</a>`
78
-
79
-
if isLoggedIn {
80
-
html += `
81
-
<a href="/current-track">Spotify Current</a>
82
-
<a href="/history">Spotify History</a>
83
-
<a href="/link-lastfm">Link Last.fm</a>` // Link to Last.fm page
84
-
if lastfmUsername != "" {
85
-
html += ` <a href="/lastfm/recent">Last.fm Recent</a>` // Show only if linked
86
-
}
87
-
html += `
88
-
<a href="/api-keys">API Keys</a>
89
-
<a href="/login/spotify">Connect Spotify Account</a>
90
-
<a href="/logout">Logout</a>`
91
-
} else {
92
-
html += `
93
-
<a href="/login/atproto">Login with ATProto</a>`
43
+
params := HomeParams{
44
+
NavBar: pages.NavBar{
45
+
IsLoggedIn: isLoggedIn,
46
+
LastFMUsername: lastfmUsername,
47
+
},
94
48
}
95
-
96
-
html += `
97
-
</div>
98
-
99
-
<div class="card">
100
-
<h2>Welcome to Piper</h2>
101
-
<p>Piper is a multi-user application that records what you're listening to on Spotify and Last.fm, saving your listening history.</p>`
102
-
103
-
if !isLoggedIn {
104
-
html += `
105
-
<p><a href="/login/atproto">Login with ATProto</a> to get started!</p>`
106
-
} else {
107
-
html += `
108
-
<p>You're logged in!</p>
109
-
<ul>
110
-
<li><a href="/login/spotify">Connect your Spotify account</a> to start tracking.</li>
111
-
<li><a href="/link-lastfm">Link your Last.fm account</a> to track scrobbles.</li>
112
-
</ul>
113
-
<p>Once connected, you can check out your:</p>
114
-
<ul>
115
-
<li><a href="/current-track">Spotify current track</a> or <a href="/history">listening history</a>.</li>`
116
-
if lastfmUsername != "" {
117
-
html += `<li><a href="/lastfm/recent">Last.fm recent tracks</a>.</li>`
118
-
}
119
-
html += `
120
-
</ul>
121
-
<p>You can also manage your <a href="/api-keys">API keys</a> for programmatic access.</p>`
122
-
if lastfmUsername != "" {
123
-
html += fmt.Sprintf("<p class='service-status'>Last.fm Username: %s</p>", lastfmUsername)
124
-
} else {
125
-
html += "<p class='service-status'>Last.fm account not linked.</p>"
126
-
}
127
-
49
+
err := pg.Execute("home", w, params)
50
+
if err != nil {
51
+
log.Printf("Error executing template: %v", err)
128
52
}
129
-
130
-
html += `
131
-
</div> <!-- Close card div -->
132
-
</body>
133
-
</html>
134
-
`
135
-
136
-
w.Write([]byte(html))
137
53
}
138
54
}
139
55
140
-
func handleLinkLastfmForm(database *db.DB) http.HandlerFunc {
56
+
func handleLinkLastfmForm(database *db.DB, pg *pages.Pages) http.HandlerFunc {
141
57
return func(w http.ResponseWriter, r *http.Request) {
142
-
userID, _ := session.GetUserID(r.Context())
58
+
userID, authenticated := session.GetUserID(r.Context())
143
59
if r.Method == http.MethodPost {
144
60
if err := r.ParseForm(); err != nil {
145
61
http.Error(w, "Failed to parse form", http.StatusBadRequest)
···
174
90
}
175
91
176
92
w.Header().Set("Content-Type", "text/html")
177
-
fmt.Fprintf(w, `
178
-
<html>
179
-
<head><title>Link Last.fm Account</title>
180
-
<style>
181
-
body { font-family: Arial, sans-serif; max-width: 600px; margin: 20px auto; padding: 20px; border: 1px solid #ddd; border-radius: 8px; }
182
-
label, input { display: block; margin-bottom: 10px; }
183
-
input[type='text'] { width: 95%%; padding: 8px; } /* Corrected width */
184
-
input[type='submit'] { padding: 10px 15px; background-color: #d51007; color: white; border: none; border-radius: 4px; cursor: pointer; }
185
-
.nav { margin-bottom: 20px; }
186
-
.nav a { margin-right: 10px; text-decoration: none; color: #1DB954; font-weight: bold; }
187
-
.error { color: red; margin-bottom: 10px; }
188
-
</style>
189
-
</head>
190
-
<body>
191
-
<div class="nav">
192
-
<a href="/">Home</a>
193
-
<a href="/link-lastfm">Link Last.fm</a>
194
-
<a href="/logout">Logout</a>
195
-
</div>
196
-
<h2>Link Your Last.fm Account</h2>
197
-
<p>Enter your Last.fm username to start tracking your scrobbles.</p>
198
-
<form method="post" action="/link-lastfm">
199
-
<label for="lastfm_username">Last.fm Username:</label>
200
-
<input type="text" id="lastfm_username" name="lastfm_username" value="%s" required>
201
-
<input type="submit" value="Save Username">
202
-
</form>
203
-
</body>
204
-
</html>`, currentUsername)
93
+
94
+
pageParams := struct {
95
+
NavBar pages.NavBar
96
+
CurrentUsername string
97
+
}{
98
+
NavBar: pages.NavBar{
99
+
IsLoggedIn: authenticated,
100
+
LastFMUsername: currentUsername,
101
+
},
102
+
CurrentUsername: currentUsername,
103
+
}
104
+
err = pg.Execute("lastFMForm", w, pageParams)
105
+
if err != nil {
106
+
log.Printf("Error executing template: %v", err)
107
+
}
205
108
}
206
109
}
207
110
···
287
190
288
191
func apiMusicBrainzSearch(mbService *musicbrainz.MusicBrainzService) http.HandlerFunc {
289
192
return func(w http.ResponseWriter, r *http.Request) {
193
+
if mbService == nil {
194
+
jsonResponse(w, http.StatusServiceUnavailable, map[string]string{"error": "MusicBrainz service is not available"})
195
+
return
196
+
}
290
197
291
198
params := musicbrainz.SearchParams{
292
199
Track: r.URL.Query().Get("track"),
···
416
323
jsonResponse(w, http.StatusOK, map[string]string{"message": "Last.fm username unlinked successfully"})
417
324
}
418
325
}
326
+
327
+
// apiSubmitListensHandler handles ListenBrainz-compatible submissions
328
+
func apiSubmitListensHandler(database *db.DB, atprotoService *atprotoauth.ATprotoAuthService, playingNowService *playingnow.PlayingNowService, mbService *musicbrainz.MusicBrainzService) http.HandlerFunc {
329
+
return func(w http.ResponseWriter, r *http.Request) {
330
+
userID, authenticated := session.GetUserID(r.Context())
331
+
if !authenticated {
332
+
jsonResponse(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"})
333
+
return
334
+
}
335
+
336
+
if r.Method != http.MethodPost {
337
+
jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"})
338
+
return
339
+
}
340
+
341
+
// Parse the ListenBrainz submission
342
+
var submission models.ListenBrainzSubmission
343
+
if err := json.NewDecoder(r.Body).Decode(&submission); err != nil {
344
+
log.Printf("apiSubmitListensHandler: Error decoding submission for user %d: %v", userID, err)
345
+
jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON format"})
346
+
return
347
+
}
348
+
349
+
// Validate listen_type
350
+
validListenTypes := map[string]bool{
351
+
"single": true,
352
+
"import": true,
353
+
"playing_now": true,
354
+
}
355
+
if !validListenTypes[submission.ListenType] {
356
+
jsonResponse(w, http.StatusBadRequest, map[string]string{
357
+
"error": "Invalid listen_type. Must be 'single', 'import', or 'playing_now'",
358
+
})
359
+
return
360
+
}
361
+
362
+
// Validate payload
363
+
if len(submission.Payload) == 0 {
364
+
jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "Payload cannot be empty"})
365
+
return
366
+
}
367
+
368
+
// Get user for PDS submission
369
+
user, err := database.GetUserByID(userID)
370
+
if err != nil {
371
+
log.Printf("apiSubmitListensHandler: Error getting user %d: %v", userID, err)
372
+
jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "Failed to get user"})
373
+
return
374
+
}
375
+
376
+
// Process each listen in the payload
377
+
var processedTracks []models.Track
378
+
var errors []string
379
+
380
+
for i, listen := range submission.Payload {
381
+
// Validate required fields
382
+
if listen.TrackMetadata.ArtistName == "" {
383
+
errors = append(errors, fmt.Sprintf("payload[%d]: artist_name is required", i))
384
+
continue
385
+
}
386
+
if listen.TrackMetadata.TrackName == "" {
387
+
errors = append(errors, fmt.Sprintf("payload[%d]: track_name is required", i))
388
+
continue
389
+
}
390
+
391
+
// Convert to internal Track format
392
+
track := listen.ConvertToTrack(userID)
393
+
394
+
// Attempt to hydrate with MusicBrainz data if service is available and track doesn't have MBIDs
395
+
if mbService != nil && track.RecordingMBID == nil {
396
+
hydratedTrack, err := musicbrainz.HydrateTrack(mbService, track)
397
+
if err != nil {
398
+
log.Printf("apiSubmitListensHandler: Could not hydrate track with MusicBrainz for user %d: %v (continuing with original data)", userID, err)
399
+
// Continue with non-hydrated track
400
+
} else if hydratedTrack != nil {
401
+
track = *hydratedTrack
402
+
log.Printf("apiSubmitListensHandler: Successfully hydrated track '%s' with MusicBrainz data", track.Name)
403
+
}
404
+
}
405
+
406
+
// For 'playing_now' type, publish to PDS as actor status
407
+
if submission.ListenType == "playing_now" {
408
+
log.Printf("Received playing_now listen for user %d: %s - %s", userID, track.Artist[0].Name, track.Name)
409
+
410
+
if user.ATProtoDID != nil && playingNowService != nil {
411
+
if err := playingNowService.PublishPlayingNow(r.Context(), userID, &track); err != nil {
412
+
log.Printf("apiSubmitListensHandler: Error publishing playing_now to PDS for user %d: %v", userID, err)
413
+
// Don't fail the request, just log the error
414
+
}
415
+
}
416
+
continue
417
+
}
418
+
419
+
// Store the track
420
+
if _, err := database.SaveTrack(userID, &track); err != nil {
421
+
log.Printf("apiSubmitListensHandler: Error saving track for user %d: %v", userID, err)
422
+
errors = append(errors, fmt.Sprintf("payload[%d]: failed to save track", i))
423
+
continue
424
+
}
425
+
426
+
// Submit to PDS as feed.play record
427
+
if user.ATProtoDID != nil && atprotoService != nil {
428
+
if err := atprotoservice.SubmitPlayToPDS(r.Context(), *user.ATProtoDID, *user.MostRecentAtProtoSessionID, &track, atprotoService); err != nil {
429
+
log.Printf("apiSubmitListensHandler: Error submitting play to PDS for user %d: %v", userID, err)
430
+
// Don't fail the request, just log the error
431
+
}
432
+
}
433
+
434
+
processedTracks = append(processedTracks, track)
435
+
}
436
+
437
+
// Prepare response
438
+
response := map[string]interface{}{
439
+
"status": "ok",
440
+
"processed": len(processedTracks),
441
+
}
442
+
443
+
if len(errors) > 0 {
444
+
response["errors"] = errors
445
+
if len(processedTracks) == 0 {
446
+
jsonResponse(w, http.StatusBadRequest, response)
447
+
return
448
+
}
449
+
}
450
+
451
+
log.Printf("Successfully processed %d ListenBrainz submissions for user %d (type: %s)",
452
+
len(processedTracks), userID, submission.ListenType)
453
+
454
+
jsonResponse(w, http.StatusOK, response)
455
+
}
456
+
}
+618
cmd/listenbrainz_test.go
+618
cmd/listenbrainz_test.go
···
1
+
package main
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"encoding/json"
7
+
"net/http"
8
+
"net/http/httptest"
9
+
"testing"
10
+
"time"
11
+
12
+
"github.com/teal-fm/piper/db"
13
+
"github.com/teal-fm/piper/models"
14
+
"github.com/teal-fm/piper/service/musicbrainz"
15
+
"github.com/teal-fm/piper/session"
16
+
)
17
+
18
+
func setupTestDB(t *testing.T) *db.DB {
19
+
// Use in-memory SQLite database for testing
20
+
database, err := db.New(":memory:")
21
+
if err != nil {
22
+
t.Fatalf("Failed to create test database: %v", err)
23
+
}
24
+
25
+
if err := database.Initialize(); err != nil {
26
+
t.Fatalf("Failed to initialize test database: %v", err)
27
+
}
28
+
29
+
return database
30
+
}
31
+
32
+
func createTestUser(t *testing.T, database *db.DB) (int64, string) {
33
+
// Create a test user
34
+
user := &models.User{
35
+
Email: func() *string { s := "test@example.com"; return &s }(),
36
+
ATProtoDID: func() *string { s := "did:test:user"; return &s }(),
37
+
}
38
+
39
+
userID, err := database.CreateUser(user)
40
+
if err != nil {
41
+
t.Fatalf("Failed to create test user: %v", err)
42
+
}
43
+
44
+
// Create API key for the user
45
+
sessionManager := session.NewSessionManager(database)
46
+
apiKeyObj, err := sessionManager.CreateAPIKey(userID, "test-key", 30) // 30 days validity
47
+
if err != nil {
48
+
t.Fatalf("Failed to create API key: %v", err)
49
+
}
50
+
51
+
return userID, apiKeyObj.ID
52
+
}
53
+
54
+
// Helper to create context with user ID (simulating auth middleware)
55
+
func withUserContext(ctx context.Context, userID int64) context.Context {
56
+
return session.WithUserID(ctx, userID)
57
+
}
58
+
59
+
func TestListenBrainzSubmission_Success(t *testing.T) {
60
+
database := setupTestDB(t)
61
+
defer database.Close()
62
+
63
+
userID, apiKey := createTestUser(t, database)
64
+
65
+
// Create test submission
66
+
submission := models.ListenBrainzSubmission{
67
+
ListenType: "single",
68
+
Payload: []models.ListenBrainzPayload{
69
+
{
70
+
ListenedAt: func() *int64 { i := int64(1704067200); return &i }(),
71
+
TrackMetadata: models.ListenBrainzTrackMetadata{
72
+
ArtistName: "Daft Punk",
73
+
TrackName: "One More Time",
74
+
ReleaseName: func() *string { s := "Discovery"; return &s }(),
75
+
AdditionalInfo: &models.ListenBrainzAdditionalInfo{
76
+
RecordingMBID: func() *string { s := "98255a8c-017a-4bc7-8dd6-1fa36124572b"; return &s }(),
77
+
ArtistMBIDs: []string{"db92a151-1ac2-438b-bc43-b82e149ddd50"},
78
+
ReleaseMBID: func() *string { s := "bf9e91ea-8029-4a04-a26a-224e00a83266"; return &s }(),
79
+
DurationMs: func() *int64 { i := int64(320000); return &i }(),
80
+
SpotifyID: func() *string { s := "4PTG3Z6ehGkBFwjybzWkR8"; return &s }(),
81
+
ISRC: func() *string { s := "GBARL0600925"; return &s }(),
82
+
},
83
+
},
84
+
},
85
+
},
86
+
}
87
+
88
+
jsonData, err := json.Marshal(submission)
89
+
if err != nil {
90
+
t.Fatalf("Failed to marshal submission: %v", err)
91
+
}
92
+
93
+
// Create request
94
+
req := httptest.NewRequest(http.MethodPost, "/1/submit-listens", bytes.NewReader(jsonData))
95
+
req.Header.Set("Content-Type", "application/json")
96
+
req.Header.Set("Authorization", "Token "+apiKey)
97
+
98
+
// Add user context (simulating authentication middleware)
99
+
ctx := withUserContext(req.Context(), userID)
100
+
req = req.WithContext(ctx)
101
+
102
+
// Create response recorder
103
+
rr := httptest.NewRecorder()
104
+
105
+
// Call handler
106
+
handler := apiSubmitListensHandler(database, nil, nil, nil)
107
+
handler(rr, req)
108
+
109
+
// Check response
110
+
if rr.Code != http.StatusOK {
111
+
t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, rr.Code, rr.Body.String())
112
+
}
113
+
114
+
// Parse response
115
+
var response map[string]interface{}
116
+
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
117
+
t.Fatalf("Failed to parse response: %v", err)
118
+
}
119
+
120
+
// Verify response
121
+
if response["status"] != "ok" {
122
+
t.Errorf("Expected status 'ok', got %v", response["status"])
123
+
}
124
+
125
+
processed, ok := response["processed"].(float64)
126
+
if !ok || processed != 1 {
127
+
t.Errorf("Expected processed count 1, got %v", response["processed"])
128
+
}
129
+
130
+
// Verify data was saved to database
131
+
tracks, err := database.GetRecentTracks(userID, 10)
132
+
if err != nil {
133
+
t.Fatalf("Failed to get tracks from database: %v", err)
134
+
}
135
+
136
+
if len(tracks) != 1 {
137
+
t.Fatalf("Expected 1 track in database, got %d", len(tracks))
138
+
}
139
+
140
+
track := tracks[0]
141
+
if track.Name != "One More Time" {
142
+
t.Errorf("Expected track name 'One More Time', got %s", track.Name)
143
+
}
144
+
if len(track.Artist) == 0 || track.Artist[0].Name != "Daft Punk" {
145
+
t.Errorf("Expected artist 'Daft Punk', got %v", track.Artist)
146
+
}
147
+
if track.Album != "Discovery" {
148
+
t.Errorf("Expected album 'Discovery', got %s", track.Album)
149
+
}
150
+
if track.RecordingMBID == nil || *track.RecordingMBID != "98255a8c-017a-4bc7-8dd6-1fa36124572b" {
151
+
t.Errorf("Expected recording MBID to be set correctly")
152
+
}
153
+
if track.DurationMs != 320000 {
154
+
t.Errorf("Expected duration 320000ms, got %d", track.DurationMs)
155
+
}
156
+
}
157
+
158
+
func TestListenBrainzSubmission_MinimalPayload(t *testing.T) {
159
+
database := setupTestDB(t)
160
+
defer database.Close()
161
+
162
+
userID, apiKey := createTestUser(t, database)
163
+
164
+
// Create minimal submission (only required fields)
165
+
submission := models.ListenBrainzSubmission{
166
+
ListenType: "single",
167
+
Payload: []models.ListenBrainzPayload{
168
+
{
169
+
TrackMetadata: models.ListenBrainzTrackMetadata{
170
+
ArtistName: "The Beatles",
171
+
TrackName: "Hey Jude",
172
+
},
173
+
},
174
+
},
175
+
}
176
+
177
+
jsonData, err := json.Marshal(submission)
178
+
if err != nil {
179
+
t.Fatalf("Failed to marshal submission: %v", err)
180
+
}
181
+
182
+
req := httptest.NewRequest(http.MethodPost, "/1/submit-listens", bytes.NewReader(jsonData))
183
+
req.Header.Set("Content-Type", "application/json")
184
+
req.Header.Set("Authorization", "Token "+apiKey)
185
+
186
+
ctx := withUserContext(req.Context(), userID)
187
+
req = req.WithContext(ctx)
188
+
189
+
rr := httptest.NewRecorder()
190
+
handler := apiSubmitListensHandler(database, nil, nil, nil)
191
+
handler(rr, req)
192
+
193
+
if rr.Code != http.StatusOK {
194
+
t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, rr.Code, rr.Body.String())
195
+
}
196
+
197
+
// Verify track was saved
198
+
tracks, err := database.GetRecentTracks(userID, 10)
199
+
if err != nil {
200
+
t.Fatalf("Failed to get tracks from database: %v", err)
201
+
}
202
+
203
+
if len(tracks) != 1 {
204
+
t.Fatalf("Expected 1 track in database, got %d", len(tracks))
205
+
}
206
+
207
+
track := tracks[0]
208
+
if track.Name != "Hey Jude" {
209
+
t.Errorf("Expected track name 'Hey Jude', got %s", track.Name)
210
+
}
211
+
if len(track.Artist) == 0 || track.Artist[0].Name != "The Beatles" {
212
+
t.Errorf("Expected artist 'The Beatles', got %v", track.Artist)
213
+
}
214
+
// Timestamp should be set to current time if not provided
215
+
if track.Timestamp.IsZero() {
216
+
t.Error("Expected timestamp to be set")
217
+
}
218
+
}
219
+
220
+
func TestListenBrainzSubmission_BulkImport(t *testing.T) {
221
+
database := setupTestDB(t)
222
+
defer database.Close()
223
+
224
+
userID, apiKey := createTestUser(t, database)
225
+
226
+
// Create bulk submission
227
+
submission := models.ListenBrainzSubmission{
228
+
ListenType: "import",
229
+
Payload: []models.ListenBrainzPayload{
230
+
{
231
+
ListenedAt: func() *int64 { i := int64(1704067200); return &i }(),
232
+
TrackMetadata: models.ListenBrainzTrackMetadata{
233
+
ArtistName: "Track One Artist",
234
+
TrackName: "Track One",
235
+
},
236
+
},
237
+
{
238
+
ListenedAt: func() *int64 { i := int64(1704067300); return &i }(),
239
+
TrackMetadata: models.ListenBrainzTrackMetadata{
240
+
ArtistName: "Track Two Artist",
241
+
TrackName: "Track Two",
242
+
},
243
+
},
244
+
{
245
+
ListenedAt: func() *int64 { i := int64(1704067400); return &i }(),
246
+
TrackMetadata: models.ListenBrainzTrackMetadata{
247
+
ArtistName: "Track Three Artist",
248
+
TrackName: "Track Three",
249
+
},
250
+
},
251
+
},
252
+
}
253
+
254
+
jsonData, err := json.Marshal(submission)
255
+
if err != nil {
256
+
t.Fatalf("Failed to marshal submission: %v", err)
257
+
}
258
+
259
+
req := httptest.NewRequest(http.MethodPost, "/1/submit-listens", bytes.NewReader(jsonData))
260
+
req.Header.Set("Content-Type", "application/json")
261
+
req.Header.Set("Authorization", "Token "+apiKey)
262
+
263
+
ctx := withUserContext(req.Context(), userID)
264
+
req = req.WithContext(ctx)
265
+
266
+
rr := httptest.NewRecorder()
267
+
handler := apiSubmitListensHandler(database, nil, nil, nil)
268
+
handler(rr, req)
269
+
270
+
if rr.Code != http.StatusOK {
271
+
t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, rr.Code, rr.Body.String())
272
+
}
273
+
274
+
// Parse response
275
+
var response map[string]interface{}
276
+
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
277
+
t.Fatalf("Failed to parse response: %v", err)
278
+
}
279
+
280
+
processed, ok := response["processed"].(float64)
281
+
if !ok || processed != 3 {
282
+
t.Errorf("Expected processed count 3, got %v", response["processed"])
283
+
}
284
+
285
+
// Verify all tracks were saved
286
+
tracks, err := database.GetRecentTracks(userID, 10)
287
+
if err != nil {
288
+
t.Fatalf("Failed to get tracks from database: %v", err)
289
+
}
290
+
291
+
if len(tracks) != 3 {
292
+
t.Fatalf("Expected 3 tracks in database, got %d", len(tracks))
293
+
}
294
+
}
295
+
296
+
func TestListenBrainzSubmission_PlayingNow(t *testing.T) {
297
+
database := setupTestDB(t)
298
+
defer database.Close()
299
+
300
+
userID, apiKey := createTestUser(t, database)
301
+
302
+
// Create playing_now submission
303
+
submission := models.ListenBrainzSubmission{
304
+
ListenType: "playing_now",
305
+
Payload: []models.ListenBrainzPayload{
306
+
{
307
+
TrackMetadata: models.ListenBrainzTrackMetadata{
308
+
ArtistName: "Current Artist",
309
+
TrackName: "Currently Playing",
310
+
},
311
+
},
312
+
},
313
+
}
314
+
315
+
jsonData, err := json.Marshal(submission)
316
+
if err != nil {
317
+
t.Fatalf("Failed to marshal submission: %v", err)
318
+
}
319
+
320
+
req := httptest.NewRequest(http.MethodPost, "/1/submit-listens", bytes.NewReader(jsonData))
321
+
req.Header.Set("Content-Type", "application/json")
322
+
req.Header.Set("Authorization", "Token "+apiKey)
323
+
324
+
ctx := withUserContext(req.Context(), userID)
325
+
req = req.WithContext(ctx)
326
+
327
+
rr := httptest.NewRecorder()
328
+
handler := apiSubmitListensHandler(database, nil, nil, nil)
329
+
handler(rr, req)
330
+
331
+
if rr.Code != http.StatusOK {
332
+
t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, rr.Code, rr.Body.String())
333
+
}
334
+
335
+
// playing_now tracks should not be permanently stored
336
+
tracks, err := database.GetRecentTracks(userID, 10)
337
+
if err != nil {
338
+
t.Fatalf("Failed to get tracks from database: %v", err)
339
+
}
340
+
341
+
if len(tracks) != 0 {
342
+
t.Errorf("Expected 0 tracks in database for playing_now, got %d", len(tracks))
343
+
}
344
+
}
345
+
346
+
func TestListenBrainzSubmission_ValidationErrors(t *testing.T) {
347
+
database := setupTestDB(t)
348
+
defer database.Close()
349
+
350
+
userID, apiKey := createTestUser(t, database)
351
+
352
+
testCases := []struct {
353
+
name string
354
+
submission models.ListenBrainzSubmission
355
+
expectedStatus int
356
+
expectedError string
357
+
}{
358
+
{
359
+
name: "invalid_listen_type",
360
+
submission: models.ListenBrainzSubmission{
361
+
ListenType: "invalid",
362
+
Payload: []models.ListenBrainzPayload{},
363
+
},
364
+
expectedStatus: http.StatusBadRequest,
365
+
expectedError: "Invalid listen_type",
366
+
},
367
+
{
368
+
name: "empty_payload",
369
+
submission: models.ListenBrainzSubmission{
370
+
ListenType: "single",
371
+
Payload: []models.ListenBrainzPayload{},
372
+
},
373
+
expectedStatus: http.StatusBadRequest,
374
+
expectedError: "Payload cannot be empty",
375
+
},
376
+
{
377
+
name: "missing_artist_name",
378
+
submission: models.ListenBrainzSubmission{
379
+
ListenType: "single",
380
+
Payload: []models.ListenBrainzPayload{
381
+
{
382
+
TrackMetadata: models.ListenBrainzTrackMetadata{
383
+
TrackName: "Track Without Artist",
384
+
},
385
+
},
386
+
},
387
+
},
388
+
expectedStatus: http.StatusBadRequest,
389
+
expectedError: "artist_name is required",
390
+
},
391
+
{
392
+
name: "missing_track_name",
393
+
submission: models.ListenBrainzSubmission{
394
+
ListenType: "single",
395
+
Payload: []models.ListenBrainzPayload{
396
+
{
397
+
TrackMetadata: models.ListenBrainzTrackMetadata{
398
+
ArtistName: "Artist Without Track",
399
+
},
400
+
},
401
+
},
402
+
},
403
+
expectedStatus: http.StatusBadRequest,
404
+
expectedError: "track_name is required",
405
+
},
406
+
}
407
+
408
+
for _, tc := range testCases {
409
+
t.Run(tc.name, func(t *testing.T) {
410
+
jsonData, err := json.Marshal(tc.submission)
411
+
if err != nil {
412
+
t.Fatalf("Failed to marshal submission: %v", err)
413
+
}
414
+
415
+
req := httptest.NewRequest(http.MethodPost, "/1/submit-listens", bytes.NewReader(jsonData))
416
+
req.Header.Set("Content-Type", "application/json")
417
+
req.Header.Set("Authorization", "Token "+apiKey)
418
+
419
+
ctx := withUserContext(req.Context(), userID)
420
+
req = req.WithContext(ctx)
421
+
422
+
rr := httptest.NewRecorder()
423
+
handler := apiSubmitListensHandler(database, nil, nil, nil)
424
+
handler(rr, req)
425
+
426
+
if rr.Code != tc.expectedStatus {
427
+
t.Errorf("Expected status %d, got %d. Body: %s", tc.expectedStatus, rr.Code, rr.Body.String())
428
+
}
429
+
430
+
if tc.expectedError != "" {
431
+
body := rr.Body.String()
432
+
if !bytes.Contains([]byte(body), []byte(tc.expectedError)) {
433
+
t.Errorf("Expected error containing '%s', got: %s", tc.expectedError, body)
434
+
}
435
+
}
436
+
})
437
+
}
438
+
}
439
+
440
+
func TestListenBrainzSubmission_Unauthorized(t *testing.T) {
441
+
database := setupTestDB(t)
442
+
defer database.Close()
443
+
444
+
submission := models.ListenBrainzSubmission{
445
+
ListenType: "single",
446
+
Payload: []models.ListenBrainzPayload{
447
+
{
448
+
TrackMetadata: models.ListenBrainzTrackMetadata{
449
+
ArtistName: "Test Artist",
450
+
TrackName: "Test Track",
451
+
},
452
+
},
453
+
},
454
+
}
455
+
456
+
jsonData, err := json.Marshal(submission)
457
+
if err != nil {
458
+
t.Fatalf("Failed to marshal submission: %v", err)
459
+
}
460
+
461
+
req := httptest.NewRequest(http.MethodPost, "/1/submit-listens", bytes.NewReader(jsonData))
462
+
req.Header.Set("Content-Type", "application/json")
463
+
// No Authorization header
464
+
465
+
rr := httptest.NewRecorder()
466
+
handler := apiSubmitListensHandler(database, nil, nil, nil)
467
+
handler(rr, req)
468
+
469
+
if rr.Code != http.StatusUnauthorized {
470
+
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code)
471
+
}
472
+
}
473
+
474
+
func TestListenBrainzDataConversion(t *testing.T) {
475
+
// Test the conversion logic directly
476
+
payload := models.ListenBrainzPayload{
477
+
ListenedAt: func() *int64 { i := int64(1704067200); return &i }(),
478
+
TrackMetadata: models.ListenBrainzTrackMetadata{
479
+
ArtistName: "Test Artist",
480
+
TrackName: "Test Track",
481
+
ReleaseName: func() *string { s := "Test Album"; return &s }(),
482
+
AdditionalInfo: &models.ListenBrainzAdditionalInfo{
483
+
RecordingMBID: func() *string { s := "test-recording-mbid"; return &s }(),
484
+
ArtistMBIDs: []string{"test-artist-mbid-1", "test-artist-mbid-2"},
485
+
ReleaseMBID: func() *string { s := "test-release-mbid"; return &s }(),
486
+
DurationMs: func() *int64 { i := int64(240000); return &i }(),
487
+
SpotifyID: func() *string { s := "test-spotify-id"; return &s }(),
488
+
ISRC: func() *string { s := "TEST1234567"; return &s }(),
489
+
},
490
+
},
491
+
}
492
+
493
+
track := payload.ConvertToTrack(123)
494
+
495
+
// Verify conversion
496
+
if track.Name != "Test Track" {
497
+
t.Errorf("Expected track name 'Test Track', got %s", track.Name)
498
+
}
499
+
if track.Album != "Test Album" {
500
+
t.Errorf("Expected album 'Test Album', got %s", track.Album)
501
+
}
502
+
if track.RecordingMBID == nil || *track.RecordingMBID != "test-recording-mbid" {
503
+
t.Errorf("Recording MBID not set correctly")
504
+
}
505
+
if track.ReleaseMBID == nil || *track.ReleaseMBID != "test-release-mbid" {
506
+
t.Errorf("Release MBID not set correctly")
507
+
}
508
+
if track.DurationMs != 240000 {
509
+
t.Errorf("Expected duration 240000ms, got %d", track.DurationMs)
510
+
}
511
+
if track.ISRC != "TEST1234567" {
512
+
t.Errorf("Expected ISRC 'TEST1234567', got %s", track.ISRC)
513
+
}
514
+
if track.URL != "https://open.spotify.com/track/test-spotify-id" {
515
+
t.Errorf("Expected Spotify URL to be constructed correctly, got %s", track.URL)
516
+
}
517
+
if track.ServiceBaseUrl != "spotify" {
518
+
t.Errorf("Expected service 'spotify', got %s", track.ServiceBaseUrl)
519
+
}
520
+
521
+
expectedTime := time.Unix(1704067200, 0)
522
+
if !track.Timestamp.Equal(expectedTime) {
523
+
t.Errorf("Expected timestamp %v, got %v", expectedTime, track.Timestamp)
524
+
}
525
+
526
+
if !track.HasStamped {
527
+
t.Error("Expected HasStamped to be true for external submissions")
528
+
}
529
+
530
+
// Check artists
531
+
if len(track.Artist) != 2 {
532
+
t.Errorf("Expected 2 artists, got %d", len(track.Artist))
533
+
}
534
+
if track.Artist[0].MBID == nil || *track.Artist[0].MBID != "test-artist-mbid-1" {
535
+
t.Errorf("First artist MBID not set correctly")
536
+
}
537
+
if track.Artist[1].MBID == nil || *track.Artist[1].MBID != "test-artist-mbid-2" {
538
+
t.Errorf("Second artist MBID not set correctly")
539
+
}
540
+
}
541
+
542
+
func TestListenBrainzSubmission_WithMusicBrainzHydration(t *testing.T) {
543
+
database := setupTestDB(t)
544
+
defer database.Close()
545
+
546
+
userID, apiKey := createTestUser(t, database)
547
+
548
+
// Create a MusicBrainz service for hydration
549
+
mbService := musicbrainz.NewMusicBrainzService(database)
550
+
551
+
// Create minimal submission (artist and track name only)
552
+
submission := models.ListenBrainzSubmission{
553
+
ListenType: "single",
554
+
Payload: []models.ListenBrainzPayload{
555
+
{
556
+
ListenedAt: func() *int64 { i := int64(1704067200); return &i }(),
557
+
TrackMetadata: models.ListenBrainzTrackMetadata{
558
+
ArtistName: "Daft Punk",
559
+
TrackName: "One More Time",
560
+
// No MBIDs provided - should be hydrated
561
+
},
562
+
},
563
+
},
564
+
}
565
+
566
+
jsonData, err := json.Marshal(submission)
567
+
if err != nil {
568
+
t.Fatalf("Failed to marshal submission: %v", err)
569
+
}
570
+
571
+
req := httptest.NewRequest(http.MethodPost, "/1/submit-listens", bytes.NewReader(jsonData))
572
+
req.Header.Set("Content-Type", "application/json")
573
+
req.Header.Set("Authorization", "Token "+apiKey)
574
+
575
+
ctx := withUserContext(req.Context(), userID)
576
+
req = req.WithContext(ctx)
577
+
578
+
rr := httptest.NewRecorder()
579
+
580
+
// Call handler with MusicBrainz service
581
+
handler := apiSubmitListensHandler(database, nil, nil, mbService)
582
+
handler(rr, req)
583
+
584
+
if rr.Code != http.StatusOK {
585
+
t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, rr.Code, rr.Body.String())
586
+
}
587
+
588
+
// Verify track was saved
589
+
tracks, err := database.GetRecentTracks(userID, 10)
590
+
if err != nil {
591
+
t.Fatalf("Failed to get tracks from database: %v", err)
592
+
}
593
+
594
+
if len(tracks) != 1 {
595
+
t.Fatalf("Expected 1 track in database, got %d", len(tracks))
596
+
}
597
+
598
+
track := tracks[0]
599
+
600
+
// The track should have been hydrated with MusicBrainz data
601
+
// Note: This test requires network access to MusicBrainz API
602
+
// In a real test environment, you might want to mock the HTTP client
603
+
if track.RecordingMBID != nil {
604
+
t.Logf("Track was hydrated with recording MBID: %s", *track.RecordingMBID)
605
+
}
606
+
607
+
if track.ReleaseMBID != nil {
608
+
t.Logf("Track was hydrated with release MBID: %s", *track.ReleaseMBID)
609
+
}
610
+
611
+
// Even if hydration fails, the track should still be saved with original data
612
+
if track.Name != "One More Time" {
613
+
t.Errorf("Expected track name 'One More Time', got %s", track.Name)
614
+
}
615
+
if len(track.Artist) == 0 || track.Artist[0].Name != "Daft Punk" {
616
+
t.Errorf("Expected artist 'Daft Punk', got %v", track.Artist)
617
+
}
618
+
}
+41
-31
cmd/main.go
+41
-31
cmd/main.go
···
5
5
"fmt"
6
6
"log"
7
7
"net/http"
8
-
"os"
9
8
"time"
9
+
10
+
"github.com/teal-fm/piper/service/lastfm"
11
+
"github.com/teal-fm/piper/service/playingnow"
10
12
11
13
"github.com/spf13/viper"
12
14
"github.com/teal-fm/piper/config"
13
15
"github.com/teal-fm/piper/db"
14
16
"github.com/teal-fm/piper/oauth"
15
17
"github.com/teal-fm/piper/oauth/atproto"
18
+
pages "github.com/teal-fm/piper/pages"
16
19
apikeyService "github.com/teal-fm/piper/service/apikey"
17
-
"github.com/teal-fm/piper/service/lastfm"
18
20
"github.com/teal-fm/piper/service/musicbrainz"
19
21
"github.com/teal-fm/piper/service/spotify"
20
22
"github.com/teal-fm/piper/session"
21
23
)
22
24
23
25
type application struct {
24
-
database *db.DB
25
-
sessionManager *session.SessionManager
26
-
oauthManager *oauth.OAuthServiceManager
27
-
spotifyService *spotify.SpotifyService
28
-
apiKeyService *apikeyService.Service
29
-
mbService *musicbrainz.MusicBrainzService
30
-
atprotoService *atproto.ATprotoAuthService
26
+
database *db.DB
27
+
sessionManager *session.SessionManager
28
+
oauthManager *oauth.OAuthServiceManager
29
+
spotifyService *spotify.SpotifyService
30
+
apiKeyService *apikeyService.Service
31
+
mbService *musicbrainz.MusicBrainzService
32
+
atprotoService *atproto.ATprotoAuthService
33
+
playingNowService *playingnow.PlayingNowService
34
+
pages *pages.Pages
31
35
}
32
36
33
37
// JSON API handlers
···
52
56
log.Fatalf("Error initializing database: %v", err)
53
57
}
54
58
59
+
sessionManager := session.NewSessionManager(database)
60
+
55
61
// --- Service Initializations ---
56
-
jwksBytes, err := os.ReadFile("./jwks.json")
57
-
if err != nil {
58
-
// run `make jwtgen`
59
-
log.Fatalf("Error reading JWK file: %v", err)
62
+
63
+
var newJwkPrivateKey = viper.GetString("atproto.client_secret_key")
64
+
if newJwkPrivateKey == "" {
65
+
fmt.Printf("You now have to set the ATPROTO_CLIENT_SECRET_KEY env var to a private key. This can be done via goat key generate -t P-256")
66
+
return
60
67
}
61
-
jwks, err := atproto.LoadJwks(jwksBytes)
62
-
if err != nil {
63
-
log.Fatalf("Error loading JWK: %v", err)
68
+
var clientSecretKeyId = viper.GetString("atproto.client_secret_key_id")
69
+
if clientSecretKeyId == "" {
70
+
fmt.Printf("You also now have to set the ATPROTO_CLIENT_SECRET_KEY_ID env var to a key ID. This needs to be persistent and unique. Here's one for you: %d", time.Now().Unix())
71
+
return
64
72
}
73
+
65
74
atprotoService, err := atproto.NewATprotoAuthService(
66
75
database,
67
-
jwks,
76
+
sessionManager,
77
+
newJwkPrivateKey,
68
78
viper.GetString("atproto.client_id"),
69
79
viper.GetString("atproto.callback_url"),
80
+
clientSecretKeyId,
70
81
)
71
82
if err != nil {
72
83
log.Fatalf("Error creating ATproto auth service: %v", err)
73
84
}
74
85
75
86
mbService := musicbrainz.NewMusicBrainzService(database)
76
-
spotifyService := spotify.NewSpotifyService(database, atprotoService, mbService)
77
-
lastfmService := lastfm.NewLastFMService(database, viper.GetString("lastfm.api_key"), mbService, atprotoService)
87
+
playingNowService := playingnow.NewPlayingNowService(database, atprotoService)
88
+
spotifyService := spotify.NewSpotifyService(database, atprotoService, mbService, playingNowService)
89
+
lastfmService := lastfm.NewLastFMService(database, viper.GetString("lastfm.api_key"), mbService, atprotoService, playingNowService)
78
90
79
-
sessionManager := session.NewSessionManager(database)
80
-
oauthManager := oauth.NewOAuthServiceManager(sessionManager)
91
+
oauthManager := oauth.NewOAuthServiceManager()
81
92
82
93
spotifyOAuth := oauth.NewOAuth2Service(
83
94
viper.GetString("spotify.client_id"),
···
93
104
apiKeyService := apikeyService.NewAPIKeyService(database, sessionManager)
94
105
95
106
app := &application{
96
-
database: database,
97
-
sessionManager: sessionManager,
98
-
oauthManager: oauthManager,
99
-
apiKeyService: apiKeyService,
100
-
mbService: mbService,
101
-
spotifyService: spotifyService,
102
-
atprotoService: atprotoService,
107
+
database: database,
108
+
sessionManager: sessionManager,
109
+
oauthManager: oauthManager,
110
+
apiKeyService: apiKeyService,
111
+
mbService: mbService,
112
+
spotifyService: spotifyService,
113
+
atprotoService: atprotoService,
114
+
playingNowService: playingNowService,
115
+
pages: pages.NewPages(),
103
116
}
104
117
105
118
trackerInterval := time.Duration(viper.GetInt("tracker.interval")) * time.Second
···
108
121
lastfmInterval = 30 * time.Second
109
122
}
110
123
111
-
if err := spotifyService.LoadAllUsers(); err != nil {
112
-
log.Printf("Warning: Failed to preload Spotify users: %v", err)
113
-
}
114
124
go spotifyService.StartListeningTracker(trackerInterval)
115
125
116
126
go lastfmService.StartListeningTracker(lastfmInterval)
+12
-6
cmd/routes.go
+12
-6
cmd/routes.go
···
11
11
func (app *application) routes() http.Handler {
12
12
mux := http.NewServeMux()
13
13
14
-
mux.HandleFunc("/", session.WithPossibleAuth(home(app.database), app.sessionManager))
14
+
//Handles static file routes
15
+
mux.Handle("/static/{file_name}", app.pages.Static())
16
+
17
+
mux.HandleFunc("/", session.WithPossibleAuth(home(app.database, app.pages), app.sessionManager))
15
18
16
19
// OAuth Routes
17
20
mux.HandleFunc("/login/spotify", app.oauthManager.HandleLogin("spotify"))
···
22
25
// Authenticated Web Routes
23
26
mux.HandleFunc("/current-track", session.WithAuth(app.spotifyService.HandleCurrentTrack, app.sessionManager))
24
27
mux.HandleFunc("/history", session.WithAuth(app.spotifyService.HandleTrackHistory, app.sessionManager))
25
-
mux.HandleFunc("/api-keys", session.WithAuth(app.apiKeyService.HandleAPIKeyManagement, app.sessionManager))
26
-
mux.HandleFunc("/link-lastfm", session.WithAuth(handleLinkLastfmForm(app.database), app.sessionManager)) // GET form
27
-
mux.HandleFunc("/link-lastfm/submit", session.WithAuth(handleLinkLastfmSubmit(app.database), app.sessionManager)) // POST submit - Changed route slightly
28
-
mux.HandleFunc("/logout", app.sessionManager.HandleLogout)
28
+
mux.HandleFunc("/api-keys", session.WithAuth(app.apiKeyService.HandleAPIKeyManagement(app.database, app.pages), app.sessionManager))
29
+
mux.HandleFunc("/link-lastfm", session.WithAuth(handleLinkLastfmForm(app.database, app.pages), app.sessionManager)) // GET form
30
+
mux.HandleFunc("/link-lastfm/submit", session.WithAuth(handleLinkLastfmSubmit(app.database), app.sessionManager)) // POST submit - Changed route slightly
31
+
mux.HandleFunc("/logout", app.oauthManager.HandleLogout("atproto"))
29
32
mux.HandleFunc("/debug/", session.WithAuth(app.sessionManager.HandleDebug, app.sessionManager))
30
33
31
34
mux.HandleFunc("/api/v1/me", session.WithAPIAuth(apiMeHandler(app.database), app.sessionManager))
···
36
39
mux.HandleFunc("/api/v1/history", session.WithAPIAuth(apiTrackHistory(app.spotifyService), app.sessionManager)) // Spotify History
37
40
mux.HandleFunc("/api/v1/musicbrainz/search", apiMusicBrainzSearch(app.mbService)) // MusicBrainz (public?)
38
41
42
+
// ListenBrainz-compatible endpoint
43
+
mux.HandleFunc("/1/submit-listens", session.WithAPIAuth(apiSubmitListensHandler(app.database, app.atprotoService, app.playingNowService, app.mbService), app.sessionManager))
44
+
39
45
serverUrlRoot := viper.GetString("server.root_url")
40
46
atpClientId := viper.GetString("atproto.client_id")
41
47
atpCallbackUrl := viper.GetString("atproto.callback_url")
42
-
mux.HandleFunc("/.well-known/client-metadata.json", func(w http.ResponseWriter, r *http.Request) {
48
+
mux.HandleFunc("/oauth-client-metadata.json", func(w http.ResponseWriter, r *http.Request) {
43
49
app.atprotoService.HandleClientMetadata(w, r, serverUrlRoot, atpClientId, atpCallbackUrl)
44
50
})
45
51
mux.HandleFunc("/oauth/jwks.json", app.atprotoService.HandleJwks)
+18
compose.yml
+18
compose.yml
+3
-3
db/apikey/apikey.go
+3
-3
db/apikey/apikey.go
···
64
64
}
65
65
apiKeyID := base64.URLEncoding.EncodeToString(b)
66
66
67
-
now := time.Now()
67
+
now := time.Now().UTC()
68
68
expiresAt := now.AddDate(0, 0, validityDays) // Default to validityDays days validity
69
69
70
70
apiKey := &ApiKey{
···
100
100
101
101
if exists {
102
102
// Check if API key is expired
103
-
if time.Now().After(apiKey.ExpiresAt) {
103
+
if time.Now().UTC().After(apiKey.ExpiresAt) {
104
104
am.DeleteApiKey(apiKeyID)
105
105
return nil, false
106
106
}
···
118
118
return nil, false
119
119
}
120
120
121
-
if time.Now().After(apiKey.ExpiresAt) {
121
+
if time.Now().UTC().After(apiKey.ExpiresAt) {
122
122
am.DeleteApiKey(apiKeyID)
123
123
return nil, false
124
124
}
+263
-161
db/atproto.go
+263
-161
db/atproto.go
···
3
3
import (
4
4
"context"
5
5
"database/sql"
6
-
"encoding/json"
7
6
"fmt"
7
+
"strings"
8
8
"time"
9
9
10
-
oauth "github.com/haileyok/atproto-oauth-golang"
11
-
"github.com/haileyok/atproto-oauth-golang/helpers"
12
-
"github.com/lestrrat-go/jwx/v2/jwk"
10
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
11
+
"github.com/bluesky-social/indigo/atproto/syntax"
13
12
"github.com/teal-fm/piper/models"
14
13
)
15
14
16
-
type ATprotoAuthData struct {
17
-
State string `json:"state"`
18
-
DID string `json:"did"`
19
-
PDSUrl string `json:"pds_url"`
20
-
AuthServerIssuer string `json:"authserver_issuer"`
21
-
PKCEVerifier string `json:"pkce_verifier"`
22
-
DPoPAuthServerNonce string `json:"dpop_authserver_nonce"`
23
-
DPoPPrivateJWK jwk.Key `json:"dpop_private_jwk"`
24
-
CreatedAt time.Time `json:"created_at"`
25
-
}
26
-
27
-
func (db *DB) SaveATprotoAuthData(data *models.ATprotoAuthData) error {
28
-
dpopPrivateJWKBytes, err := json.Marshal(data.DPoPPrivateJWK)
29
-
if err != nil {
30
-
return err
31
-
}
32
-
33
-
_, err = db.Exec(`
34
-
INSERT INTO atproto_auth_data (state, did, pds_url, authserver_issuer, pkce_verifier, dpop_authserver_nonce, dpop_private_jwk)
35
-
VALUES (?, ?, ?, ?, ?, ?, ?)`,
36
-
data.State, data.DID, data.PDSUrl, data.AuthServerIssuer, data.PKCEVerifier, data.DPoPAuthServerNonce, string(dpopPrivateJWKBytes))
37
-
38
-
return err
39
-
}
40
-
41
-
func (db *DB) GetATprotoAuthData(state string) (*models.ATprotoAuthData, error) {
42
-
var data models.ATprotoAuthData
43
-
var dpopPrivateJWKString string
44
-
45
-
err := db.QueryRow(`
46
-
SELECT state, did, pds_url, authserver_issuer, pkce_verifier, dpop_authserver_nonce, dpop_private_jwk
47
-
FROM atproto_auth_data
48
-
WHERE state = ?`,
49
-
state).Scan(
50
-
&data.State,
51
-
&data.DID,
52
-
&data.PDSUrl,
53
-
&data.AuthServerIssuer,
54
-
&data.PKCEVerifier,
55
-
&data.DPoPAuthServerNonce,
56
-
&dpopPrivateJWKString,
57
-
)
58
-
if err != nil {
59
-
if err == sql.ErrNoRows {
60
-
return nil, fmt.Errorf("no auth data found for state %s: %w", state, err)
61
-
}
62
-
return nil, fmt.Errorf("failed to scan auth data for state %s: %w", state, err)
63
-
}
64
-
65
-
key, err := helpers.ParseJWKFromBytes([]byte(dpopPrivateJWKString))
66
-
if err != nil {
67
-
return nil, fmt.Errorf("failed to parse DPoPPrivateJWK for state %s: %w", state, err)
68
-
}
69
-
data.DPoPPrivateJWK = key
70
-
71
-
return &data, nil
72
-
}
73
-
74
15
func (db *DB) FindOrCreateUserByDID(did string) (*models.User, error) {
75
16
var user models.User
76
17
err := db.QueryRow(`
···
80
21
did).Scan(&user.ID, &user.ATProtoDID, &user.CreatedAt, &user.UpdatedAt)
81
22
82
23
if err == sql.ErrNoRows {
83
-
now := time.Now()
24
+
now := time.Now().UTC()
84
25
// create user!
85
26
result, insertErr := db.Exec(`
86
27
INSERT INTO users (atproto_did, created_at, updated_at)
···
108
49
return &user, err
109
50
}
110
51
111
-
// create or update the current user's ATproto session data.
112
-
func (db *DB) SaveATprotoSession(tokenResp *oauth.TokenResponse, authserverIss string, dpopPrivateJWK jwk.Key, pdsUrl string) error {
113
-
fmt.Printf("Saving session with PDS url %s", pdsUrl)
114
-
expiryTime := time.Now().Add(time.Second * time.Duration(tokenResp.ExpiresIn))
115
-
now := time.Now()
116
-
117
-
dpopPrivateJWKBytes, err := json.Marshal(dpopPrivateJWK)
118
-
if err != nil {
119
-
return err
120
-
}
52
+
func (db *DB) SetLatestATProtoSessionId(did string, atProtoSessionID string) error {
53
+
db.logger.Printf("Setting latest atproto session id for did %s to %s", did, atProtoSessionID)
54
+
now := time.Now().UTC()
121
55
122
56
result, err := db.Exec(`
123
57
UPDATE users
124
-
SET atproto_access_token = ?,
125
-
atproto_refresh_token = ?,
126
-
atproto_token_expiry = ?,
127
-
atproto_scope = ?,
128
-
atproto_sub = ?,
129
-
atproto_authserver_issuer = ?,
130
-
atproto_token_type = ?,
131
-
atproto_authserver_nonce = ?,
132
-
atproto_dpop_private_jwk = ?,
133
-
atproto_pds_url = ?,
134
-
atproto_pds_nonce = ?,
58
+
SET
59
+
most_recent_at_session_id = ?,
135
60
updated_at = ?
136
61
WHERE atproto_did = ?`,
137
-
tokenResp.AccessToken,
138
-
tokenResp.RefreshToken,
139
-
expiryTime,
140
-
tokenResp.Scope,
141
-
tokenResp.Sub,
142
-
authserverIss,
143
-
tokenResp.TokenType,
144
-
tokenResp.DpopAuthserverNonce,
145
-
string(dpopPrivateJWKBytes),
146
-
pdsUrl,
147
-
// will get set later
148
-
"",
62
+
atProtoSessionID,
149
63
now,
150
-
tokenResp.Sub,
64
+
did,
151
65
)
152
-
153
66
if err != nil {
154
-
return fmt.Errorf("failed to update atproto session for did %s: %w", tokenResp.Sub, err)
67
+
db.logger.Printf("%v", err)
68
+
return fmt.Errorf("failed to update atproto session for did %s: %w", did, atProtoSessionID)
155
69
}
156
70
157
71
rowsAffected, err := result.RowsAffected()
158
72
if err != nil {
159
73
// it's possible the update succeeded here?
160
-
return fmt.Errorf("failed to check rows affected after updating atproto session for did %s: %w", tokenResp.Sub, err)
74
+
return fmt.Errorf("failed to check rows affected after updating atproto session for did %s: %w", did, atProtoSessionID)
161
75
}
162
76
163
77
if rowsAffected == 0 {
164
-
return fmt.Errorf("no user found with did %s to update session, creating new session", tokenResp.Sub)
78
+
return fmt.Errorf("no user found with did %s to update session, creating new session", did)
165
79
}
166
80
167
81
return nil
168
82
}
169
83
170
-
func (db *DB) GetAtprotoSession(did string, ctx context.Context, oauthClient oauth.Client) (*models.ATprotoAuthSession, error) {
171
-
var oauthSession models.ATprotoAuthSession
172
-
var authserverIss string
173
-
var jwkBytes string
84
+
type SqliteATProtoStore struct {
85
+
db *sql.DB
86
+
}
87
+
88
+
var _ oauth.ClientAuthStore = (*SqliteATProtoStore)(nil)
89
+
90
+
func NewSqliteATProtoStore(db *sql.DB) *SqliteATProtoStore {
91
+
return &SqliteATProtoStore{
92
+
db: db,
93
+
}
94
+
}
95
+
96
+
func sessionKey(did syntax.DID, sessionID string) string {
97
+
return fmt.Sprintf("%s/%s", did, sessionID)
98
+
}
99
+
100
+
func splitScopes(s string) []string {
101
+
if s == "" {
102
+
return nil
103
+
}
104
+
return strings.Fields(s)
105
+
}
174
106
175
-
err := db.QueryRow(`
176
-
SELECT id, atproto_did, atproto_pds_url, atproto_authserver_issuer, atproto_access_token, atproto_refresh_token, atproto_pds_nonce, atproto_authserver_nonce, atproto_dpop_private_jwk, atproto_token_expiry
177
-
FROM users
178
-
WHERE atproto_did = ? OR id`,
179
-
did,
180
-
).Scan(
181
-
&oauthSession.ID,
182
-
&oauthSession.DID,
183
-
&oauthSession.PDSUrl,
184
-
&authserverIss,
185
-
&oauthSession.AccessToken,
186
-
&oauthSession.RefreshToken,
187
-
&oauthSession.DpopPdsNonce,
188
-
&oauthSession.DpopAuthServerNonce,
189
-
&jwkBytes,
190
-
&oauthSession.TokenExpiry,
107
+
func joinScopes(scopes []string) string {
108
+
if len(scopes) == 0 {
109
+
return ""
110
+
}
111
+
return strings.Join(scopes, " ")
112
+
}
113
+
114
+
func (s *SqliteATProtoStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
115
+
lookUpKey := sessionKey(did, sessionID)
116
+
117
+
var (
118
+
accountDIDStr string
119
+
lookUpKeyStr string
120
+
sessionIDStr string
121
+
hostURL string
122
+
authServerURL string
123
+
authServerTokenEndpoint string
124
+
authServerRevocationEndpoint string
125
+
scopesStr string
126
+
accessToken string
127
+
refreshToken string
128
+
dpopAuthServerNonce string
129
+
dpopHostNonce string
130
+
dpopPrivateKeyMultibase string
191
131
)
192
132
133
+
err := s.db.QueryRow(`
134
+
SELECT account_did,
135
+
look_up_key,
136
+
session_id,
137
+
host_url,
138
+
authserver_url,
139
+
authserver_token_endpoint,
140
+
authserver_revocation_endpoint,
141
+
scopes,
142
+
access_token,
143
+
refresh_token,
144
+
dpop_authserver_nonce,
145
+
dpop_host_nonce,
146
+
dpop_privatekey_multibase
147
+
FROM atproto_sessions
148
+
WHERE look_up_key = ?
149
+
`, lookUpKey).Scan(
150
+
&accountDIDStr,
151
+
&lookUpKeyStr,
152
+
&sessionIDStr,
153
+
&hostURL,
154
+
&authServerURL,
155
+
&authServerTokenEndpoint,
156
+
&authServerRevocationEndpoint,
157
+
&scopesStr,
158
+
&accessToken,
159
+
&refreshToken,
160
+
&dpopAuthServerNonce,
161
+
&dpopHostNonce,
162
+
&dpopPrivateKeyMultibase,
163
+
)
164
+
165
+
if err == sql.ErrNoRows {
166
+
return nil, fmt.Errorf("session not found: %s", lookUpKey)
167
+
}
193
168
if err != nil {
194
-
return nil, fmt.Errorf("failed to get atproto session for did %s: %w", did, err)
169
+
return nil, err
195
170
}
196
171
197
-
privateJwk, err := helpers.ParseJWKFromBytes([]byte(jwkBytes))
172
+
accDID, err := syntax.ParseDID(accountDIDStr)
198
173
if err != nil {
199
-
return nil, fmt.Errorf("failed to parse DPoPPrivateJWK: %w", err)
200
-
} else {
201
-
// add jwk to the struct
202
-
oauthSession.DpopPrivateJWK = privateJwk
174
+
return nil, fmt.Errorf("invalid account DID in session: %w", err)
203
175
}
204
176
205
-
// printout the session details
206
-
fmt.Printf("Session details from DB: %+v\n", oauthSession)
177
+
sess := oauth.ClientSessionData{
178
+
AccountDID: accDID,
179
+
SessionID: sessionIDStr,
180
+
HostURL: hostURL,
181
+
AuthServerURL: authServerURL,
182
+
AuthServerTokenEndpoint: authServerTokenEndpoint,
183
+
AuthServerRevocationEndpoint: authServerRevocationEndpoint,
184
+
Scopes: splitScopes(scopesStr),
185
+
AccessToken: accessToken,
186
+
RefreshToken: refreshToken,
187
+
DPoPAuthServerNonce: dpopAuthServerNonce,
188
+
DPoPHostNonce: dpopHostNonce,
189
+
DPoPPrivateKeyMultibase: dpopPrivateKeyMultibase,
190
+
}
207
191
208
-
// if token is expired, refresh it
209
-
if time.Now().After(oauthSession.TokenExpiry) {
192
+
return &sess, nil
193
+
}
210
194
211
-
resp, err := oauthClient.RefreshTokenRequest(ctx, oauthSession.RefreshToken, authserverIss, oauthSession.DpopAuthServerNonce, privateJwk)
212
-
if err != nil {
213
-
return nil, err
214
-
}
195
+
func (s *SqliteATProtoStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
196
+
lookUpKey := sessionKey(sess.AccountDID, sess.SessionID)
197
+
// simple upsert: delete then insert
198
+
_, _ = s.db.Exec(`DELETE FROM atproto_sessions WHERE look_up_key = ?`, lookUpKey)
199
+
_, err := s.db.Exec(`
200
+
INSERT INTO atproto_sessions (
201
+
look_up_key,
202
+
account_did,
203
+
session_id,
204
+
host_url,
205
+
authserver_url,
206
+
authserver_token_endpoint,
207
+
authserver_revocation_endpoint,
208
+
scopes,
209
+
access_token,
210
+
refresh_token,
211
+
dpop_authserver_nonce,
212
+
dpop_host_nonce,
213
+
dpop_privatekey_multibase
214
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
215
+
`,
216
+
lookUpKey,
217
+
sess.AccountDID.String(),
218
+
sess.SessionID,
219
+
sess.HostURL,
220
+
sess.AuthServerURL,
221
+
sess.AuthServerTokenEndpoint,
222
+
sess.AuthServerRevocationEndpoint,
223
+
joinScopes(sess.Scopes),
224
+
sess.AccessToken,
225
+
sess.RefreshToken,
226
+
sess.DPoPAuthServerNonce,
227
+
sess.DPoPHostNonce,
228
+
sess.DPoPPrivateKeyMultibase,
229
+
)
230
+
return err
231
+
}
215
232
216
-
if err := db.SaveATprotoSession(resp, authserverIss, privateJwk, oauthSession.PDSUrl); err != nil {
217
-
return nil, fmt.Errorf("failed to save refreshed token: %w", err)
218
-
}
233
+
func (s *SqliteATProtoStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
234
+
lookUpKey := sessionKey(did, sessionID)
235
+
_, err := s.db.Exec(`DELETE FROM atproto_sessions WHERE look_up_key = ?`, lookUpKey)
236
+
return err
237
+
}
219
238
220
-
oauthSession = models.ATprotoAuthSession{
221
-
ID: oauthSession.ID,
222
-
DID: oauthSession.DID,
223
-
PDSUrl: oauthSession.PDSUrl,
224
-
AuthServerIssuer: authserverIss,
225
-
AccessToken: resp.AccessToken,
226
-
RefreshToken: resp.RefreshToken,
227
-
DpopPdsNonce: oauthSession.DpopPdsNonce,
228
-
DpopAuthServerNonce: resp.DpopAuthserverNonce,
229
-
DpopPrivateJWK: privateJwk,
230
-
TokenExpiry: time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second),
239
+
func (s *SqliteATProtoStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
240
+
var (
241
+
authServerURL string
242
+
accountDIDStr sql.NullString
243
+
scopesStr string
244
+
requestURI string
245
+
authServerTokenEndpoint string
246
+
authServerRevocationEndpoint string
247
+
pkceVerifier string
248
+
dpopAuthServerNonce string
249
+
dpopPrivateKeyMultibase string
250
+
)
251
+
err := s.db.QueryRow(`
252
+
SELECT authserver_url,
253
+
account_did,
254
+
scopes,
255
+
request_uri,
256
+
authserver_token_endpoint,
257
+
authserver_revocation_endpoint,
258
+
pkce_verifier,
259
+
dpop_authserver_nonce,
260
+
dpop_privatekey_multibase
261
+
FROM atproto_state
262
+
WHERE state = ?
263
+
`, state).Scan(
264
+
&authServerURL,
265
+
&accountDIDStr,
266
+
&scopesStr,
267
+
&requestURI,
268
+
&authServerTokenEndpoint,
269
+
&authServerRevocationEndpoint,
270
+
&pkceVerifier,
271
+
&dpopAuthServerNonce,
272
+
&dpopPrivateKeyMultibase,
273
+
)
274
+
if err == sql.ErrNoRows {
275
+
return nil, fmt.Errorf("request info not found: %s", state)
276
+
}
277
+
if err != nil {
278
+
return nil, err
279
+
}
280
+
var accountDIDPtr *syntax.DID
281
+
if accountDIDStr.Valid && accountDIDStr.String != "" {
282
+
acc, err := syntax.ParseDID(accountDIDStr.String)
283
+
if err != nil {
284
+
return nil, fmt.Errorf("invalid account DID in auth request: %w", err)
231
285
}
232
-
286
+
accountDIDPtr = &acc
233
287
}
234
-
235
-
return &oauthSession, nil
288
+
info := oauth.AuthRequestData{
289
+
State: state,
290
+
AuthServerURL: authServerURL,
291
+
AccountDID: accountDIDPtr,
292
+
Scopes: splitScopes(scopesStr),
293
+
RequestURI: requestURI,
294
+
AuthServerTokenEndpoint: authServerTokenEndpoint,
295
+
AuthServerRevocationEndpoint: authServerRevocationEndpoint,
296
+
PKCEVerifier: pkceVerifier,
297
+
DPoPAuthServerNonce: dpopAuthServerNonce,
298
+
DPoPPrivateKeyMultibase: dpopPrivateKeyMultibase,
299
+
}
300
+
return &info, nil
236
301
}
237
302
238
-
func AtpSessionToAuthArgs(sess *models.ATprotoAuthSession) *oauth.XrpcAuthedRequestArgs {
239
-
fmt.Printf("DID: %s\nPDS URL: %s\nISS: %s\nAccess Token: %s\nNonce: %s\nPrivate JWK: %s\n", sess.DID, sess.PDSUrl, sess.AuthServerIssuer, sess.AccessToken, sess.DpopPdsNonce, sess.DpopPrivateJWK)
240
-
return &oauth.XrpcAuthedRequestArgs{
241
-
Did: sess.DID,
242
-
PdsUrl: sess.PDSUrl,
243
-
Issuer: sess.AuthServerIssuer,
244
-
AccessToken: sess.AccessToken,
245
-
DpopPdsNonce: sess.DpopPdsNonce,
246
-
DpopPrivateJwk: sess.DpopPrivateJWK,
303
+
func (s *SqliteATProtoStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
304
+
// ensure not already exists
305
+
var exists int
306
+
err := s.db.QueryRow(`SELECT 1 FROM atproto_state WHERE state = ?`, info.State).Scan(&exists)
307
+
if err == nil {
308
+
return fmt.Errorf("auth request already saved for state %s", info.State)
309
+
}
310
+
if err != nil && err != sql.ErrNoRows {
311
+
return err
312
+
}
313
+
var accountDIDStr interface{}
314
+
if info.AccountDID != nil {
315
+
accountDIDStr = info.AccountDID.String()
316
+
} else {
317
+
accountDIDStr = nil
247
318
}
319
+
_, err = s.db.Exec(`
320
+
INSERT INTO atproto_state (
321
+
state,
322
+
authserver_url,
323
+
account_did,
324
+
scopes,
325
+
request_uri,
326
+
authserver_token_endpoint,
327
+
authserver_revocation_endpoint,
328
+
pkce_verifier,
329
+
dpop_authserver_nonce,
330
+
dpop_privatekey_multibase
331
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
332
+
`,
333
+
info.State,
334
+
info.AuthServerURL,
335
+
accountDIDStr,
336
+
joinScopes(info.Scopes),
337
+
info.RequestURI,
338
+
info.AuthServerTokenEndpoint,
339
+
info.AuthServerRevocationEndpoint,
340
+
info.PKCEVerifier,
341
+
info.DPoPAuthServerNonce,
342
+
info.DPoPPrivateKeyMultibase,
343
+
)
344
+
return err
345
+
}
346
+
347
+
func (s *SqliteATProtoStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
348
+
_, err := s.db.Exec(`DELETE FROM atproto_state WHERE state = ?`, state)
349
+
return err
248
350
}
+97
-53
db/db.go
+97
-53
db/db.go
···
4
4
"database/sql"
5
5
"encoding/json"
6
6
"fmt"
7
+
"log"
7
8
"os"
8
9
"path/filepath"
9
10
"time"
···
14
15
15
16
type DB struct {
16
17
*sql.DB
18
+
logger *log.Logger
17
19
}
18
20
19
21
func New(dbPath string) (*DB, error) {
···
31
33
if err = db.Ping(); err != nil {
32
34
return nil, err
33
35
}
36
+
logger := log.New(os.Stdout, "db: ", log.LstdFlags|log.Lmsgprefix)
34
37
35
-
return &DB{db}, nil
38
+
return &DB{db, logger}, nil
36
39
}
37
40
38
41
func (db *DB) Initialize() error {
···
42
45
username TEXT, -- Made nullable, might not have username initially
43
46
email TEXT UNIQUE, -- Made nullable
44
47
atproto_did TEXT UNIQUE, -- Atproto DID (identifier)
45
-
atproto_pds_url TEXT,
46
-
atproto_authserver_issuer TEXT,
47
-
atproto_access_token TEXT, -- Atproto access token
48
-
atproto_refresh_token TEXT, -- Atproto refresh token
49
-
atproto_token_expiry TIMESTAMP, -- Atproto token expiry
50
-
atproto_sub TEXT,
51
-
atproto_scope TEXT, -- Atproto token scope
52
-
atproto_token_type TEXT, -- Atproto token type
53
-
atproto_authserver_nonce TEXT,
54
-
atproto_pds_nonce TEXT,
55
-
atproto_dpop_private_jwk TEXT,
48
+
most_recent_at_session_id TEXT, -- Most recent oAuth session id
56
49
spotify_id TEXT UNIQUE, -- Spotify specific ID
57
50
access_token TEXT, -- Spotify access token
58
51
refresh_token TEXT, -- Spotify refresh token
···
88
81
}
89
82
90
83
_, err = db.Exec(`
91
-
CREATE TABLE IF NOT EXISTS atproto_auth_data (
92
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
93
-
state TEXT NOT NULL,
94
-
did TEXT,
95
-
pds_url TEXT NOT NULL,
96
-
authserver_issuer TEXT NOT NULL,
97
-
pkce_verifier TEXT NOT NULL,
98
-
dpop_authserver_nonce TEXT NOT NULL,
99
-
dpop_private_jwk TEXT NOT NULL,
100
-
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
101
-
)`)
84
+
CREATE TABLE IF NOT EXISTS atproto_state (
85
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
86
+
state TEXT NOT NULL,
87
+
authserver_url TEXT,
88
+
account_did TEXT,
89
+
scopes TEXT,
90
+
request_uri TEXT,
91
+
authserver_token_endpoint TEXT,
92
+
authserver_revocation_endpoint TEXT,
93
+
pkce_verifier TEXT,
94
+
dpop_authserver_nonce TEXT,
95
+
dpop_privatekey_multibase TEXT,
96
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
97
+
);
98
+
CREATE INDEX IF NOT EXISTS atproto_state_state ON atproto_state(state);
99
+
100
+
`)
101
+
if err != nil {
102
+
return err
103
+
}
104
+
105
+
_, err = db.Exec(`
106
+
CREATE TABLE IF NOT EXISTS atproto_sessions (
107
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
108
+
look_up_key TEXT NOT NULL,
109
+
account_did TEXT,
110
+
session_id TEXT,
111
+
host_url TEXT,
112
+
authserver_url TEXT,
113
+
authserver_token_endpoint TEXT,
114
+
authserver_revocation_endpoint TEXT,
115
+
scopes TEXT,
116
+
access_token TEXT,
117
+
refresh_token TEXT,
118
+
dpop_authserver_nonce TEXT,
119
+
dpop_host_nonce TEXT,
120
+
dpop_privatekey_multibase TEXT,
121
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
122
+
);
123
+
CREATE INDEX IF NOT EXISTS idx_atproto_sessions_look_up_key ON atproto_sessions(look_up_key);
124
+
`)
102
125
if err != nil {
103
126
return err
104
127
}
···
120
143
121
144
// create user without spotify id
122
145
func (db *DB) CreateUser(user *models.User) (int64, error) {
123
-
now := time.Now()
146
+
now := time.Now().UTC()
124
147
125
148
result, err := db.Exec(`
126
149
INSERT INTO users (username, email, created_at, updated_at)
···
136
159
137
160
// add spotify session to user, returning the updated user
138
161
func (db *DB) AddSpotifySession(userID int64, username, email, spotifyId, accessToken, refreshToken string, tokenExpiry time.Time) (*models.User, error) {
139
-
now := time.Now()
162
+
now := time.Now().UTC()
140
163
141
164
_, err := db.Exec(`
142
165
UPDATE users SET username = ?, email = ?, spotify_id = ?, access_token = ?, refresh_token = ?, token_expiry = ?, created_at = ?, updated_at = ?
···
159
182
user := &models.User{}
160
183
161
184
err := db.QueryRow(`
162
-
SELECT id, username, email, atproto_did, spotify_id, access_token, refresh_token, token_expiry, lastfm_username, created_at, updated_at
185
+
SELECT id,
186
+
username,
187
+
email,
188
+
atproto_did,
189
+
most_recent_at_session_id,
190
+
spotify_id,
191
+
access_token,
192
+
refresh_token,
193
+
token_expiry,
194
+
lastfm_username,
195
+
created_at,
196
+
updated_at
163
197
FROM users WHERE id = ?`, ID).Scan(
164
-
&user.ID, &user.Username, &user.Email, &user.ATProtoDID, &user.SpotifyID,
198
+
&user.ID, &user.Username, &user.Email, &user.ATProtoDID, &user.MostRecentAtProtoSessionID, &user.SpotifyID,
165
199
&user.AccessToken, &user.RefreshToken, &user.TokenExpiry,
166
200
&user.LastFMUsername,
167
201
&user.CreatedAt, &user.UpdatedAt)
···
200
234
}
201
235
202
236
func (db *DB) UpdateUserToken(userID int64, accessToken, refreshToken string, expiry time.Time) error {
203
-
now := time.Now()
237
+
now := time.Now().UTC()
204
238
205
239
_, err := db.Exec(`
206
240
UPDATE users
···
321
355
return tracks, nil
322
356
}
323
357
324
-
func (db *DB) GetUsersWithExpiredTokens() ([]*models.User, error) {
325
-
rows, err := db.Query(`
326
-
SELECT id, username, email, spotify_id, access_token, refresh_token, token_expiry, created_at, updated_at
327
-
FROM users
328
-
WHERE refresh_token IS NOT NULL AND token_expiry < ?
329
-
ORDER BY id`, time.Now())
330
-
331
-
if err != nil {
332
-
return nil, err
333
-
}
334
-
defer rows.Close()
358
+
// SpotifyQueryMapping maps Spotify sql query results to user structs
359
+
func SpotifyQueryMapping(rows *sql.Rows) ([]*models.User, error) {
335
360
336
361
var users []*models.User
337
362
···
350
375
return users, nil
351
376
}
352
377
378
+
func (db *DB) GetUsersWithExpiredTokens() ([]*models.User, error) {
379
+
rows, err := db.Query(`
380
+
SELECT id, username, email, spotify_id, access_token, refresh_token, token_expiry, created_at, updated_at
381
+
FROM users
382
+
WHERE refresh_token IS NOT NULL AND token_expiry < ?
383
+
ORDER BY id`, time.Now().UTC())
384
+
385
+
if err != nil {
386
+
return nil, err
387
+
}
388
+
defer rows.Close()
389
+
390
+
return SpotifyQueryMapping(rows)
391
+
392
+
}
393
+
353
394
func (db *DB) GetAllActiveUsers() ([]*models.User, error) {
354
395
rows, err := db.Query(`
355
396
SELECT id, username, email, spotify_id, access_token, refresh_token, token_expiry, created_at, updated_at
356
397
FROM users
357
-
WHERE access_token IS NOT NULL AND token_expiry > ?
358
-
ORDER BY id`, time.Now())
398
+
WHERE access_token IS NOT NULL
399
+
ORDER BY id`)
359
400
360
401
if err != nil {
361
402
return nil, err
362
403
}
363
404
defer rows.Close()
364
405
365
-
var users []*models.User
406
+
return SpotifyQueryMapping(rows)
407
+
}
408
+
409
+
func (db *DB) GetAllActiveUsersWithUnExpiredTokens() ([]*models.User, error) {
410
+
rows, err := db.Query(`
411
+
SELECT id, username, email, spotify_id, access_token, refresh_token, token_expiry, created_at, updated_at
412
+
FROM users
413
+
WHERE access_token IS NOT NULL AND token_expiry > ?
414
+
ORDER BY id`, time.Now().UTC())
366
415
367
-
for rows.Next() {
368
-
user := &models.User{}
369
-
err := rows.Scan(
370
-
&user.ID, &user.Username, &user.Email, &user.SpotifyID,
371
-
&user.AccessToken, &user.RefreshToken, &user.TokenExpiry,
372
-
&user.CreatedAt, &user.UpdatedAt)
373
-
if err != nil {
374
-
return nil, err
375
-
}
376
-
users = append(users, user)
416
+
if err != nil {
417
+
return nil, err
377
418
}
419
+
defer rows.Close()
378
420
379
-
return users, nil
421
+
return SpotifyQueryMapping(rows)
380
422
}
381
423
382
424
// debug to view current user's information
···
461
503
462
504
return &lastTimestamp, nil
463
505
}
506
+
507
+
//
+2
-2
db/lfm.go
+2
-2
db/lfm.go
···
42
42
43
43
func (db *DB) GetUserByLastFM(lastfmUsername string) (*models.User, error) {
44
44
row := db.QueryRow(`
45
-
SELECT id, username, email, atproto_did, created_at, updated_at, lastfm_username
45
+
SELECT id, username, email, atproto_did, most_recent_at_session_id, created_at, updated_at, lastfm_username
46
46
FROM users
47
47
WHERE lastfm_username = ?`, lastfmUsername)
48
48
49
49
user := &models.User{}
50
50
err := row.Scan(
51
-
&user.ID, &user.Username, &user.Email, &user.ATProtoDID,
51
+
&user.ID, &user.Username, &user.Email, &user.ATProtoDID, &user.MostRecentAtProtoSessionID,
52
52
&user.CreatedAt, &user.UpdatedAt, &user.LastFMUsername)
53
53
if err != nil {
54
54
return nil, err
+29
-3
go.mod
+29
-3
go.mod
···
3
3
go 1.24.0
4
4
5
5
require (
6
-
github.com/bluesky-social/indigo v0.0.0-20250506174012-7075cf22f63e
6
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e
7
7
github.com/dlclark/regexp2 v1.11.5
8
-
github.com/haileyok/atproto-oauth-golang v0.0.2
9
8
github.com/ipfs/go-cid v0.4.1
10
9
github.com/joho/godotenv v1.5.1
11
10
github.com/justinas/alice v1.2.0
···
21
20
require (
22
21
dario.cat/mergo v1.0.1 // indirect
23
22
github.com/air-verse/air v1.61.7 // indirect
23
+
github.com/beorn7/perks v1.0.1 // indirect
24
24
github.com/bep/godartsass v1.2.0 // indirect
25
25
github.com/bep/godartsass/v2 v2.1.0 // indirect
26
26
github.com/bep/golibsass v1.2.0 // indirect
27
27
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
28
+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
28
29
github.com/cli/safeexec v1.0.1 // indirect
29
30
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
30
31
github.com/creack/pty v1.1.23 // indirect
···
39
40
github.com/goccy/go-json v0.10.2 // indirect
40
41
github.com/gogo/protobuf v1.3.2 // indirect
41
42
github.com/gohugoio/hugo v0.134.3 // indirect
42
-
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
43
+
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
44
+
github.com/google/go-querystring v1.1.0 // indirect
43
45
github.com/google/uuid v1.6.0 // indirect
46
+
github.com/gorilla/context v1.1.2 // indirect
47
+
github.com/gorilla/securecookie v1.1.2 // indirect
48
+
github.com/gorilla/sessions v1.4.0 // indirect
44
49
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
45
50
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
46
51
github.com/hashicorp/golang-lru v1.0.2 // indirect
52
+
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
47
53
github.com/ipfs/bbloom v0.0.4 // indirect
48
54
github.com/ipfs/go-block-format v0.2.0 // indirect
49
55
github.com/ipfs/go-datastore v0.6.0 // indirect
···
56
62
github.com/ipfs/go-log/v2 v2.5.1 // indirect
57
63
github.com/ipfs/go-metrics-interface v0.0.1 // indirect
58
64
github.com/jbenet/goprocess v0.1.4 // indirect
65
+
github.com/jinzhu/inflection v1.0.0 // indirect
66
+
github.com/jinzhu/now v1.1.5 // indirect
59
67
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
68
+
github.com/labstack/echo-contrib v0.17.2 // indirect
69
+
github.com/labstack/echo/v4 v4.13.3 // indirect
70
+
github.com/labstack/gommon v0.4.2 // indirect
60
71
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
61
72
github.com/lestrrat-go/httpcc v1.0.1 // indirect
62
73
github.com/lestrrat-go/httprc v1.0.4 // indirect
···
64
75
github.com/lestrrat-go/option v1.0.1 // indirect
65
76
github.com/mattn/go-colorable v0.1.13 // indirect
66
77
github.com/mattn/go-isatty v0.0.20 // indirect
78
+
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
67
79
github.com/minio/sha256-simd v1.0.1 // indirect
68
80
github.com/mr-tron/base58 v1.2.0 // indirect
69
81
github.com/multiformats/go-base32 v0.1.0 // indirect
···
71
83
github.com/multiformats/go-multibase v0.2.0 // indirect
72
84
github.com/multiformats/go-multihash v0.2.3 // indirect
73
85
github.com/multiformats/go-varint v0.0.7 // indirect
86
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
74
87
github.com/opentracing/opentracing-go v1.2.0 // indirect
75
88
github.com/pelletier/go-toml v1.9.5 // indirect
76
89
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
77
90
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
91
+
github.com/prometheus/client_golang v1.20.5 // indirect
92
+
github.com/prometheus/client_model v0.6.1 // indirect
93
+
github.com/prometheus/common v0.61.0 // indirect
94
+
github.com/prometheus/procfs v0.15.1 // indirect
78
95
github.com/russross/blackfriday/v2 v2.1.0 // indirect
79
96
github.com/sagikazarmark/locafero v0.7.0 // indirect
97
+
github.com/samber/lo v1.47.0 // indirect
98
+
github.com/samber/slog-echo v1.15.1 // indirect
80
99
github.com/segmentio/asm v1.2.0 // indirect
81
100
github.com/sourcegraph/conc v0.3.0 // indirect
82
101
github.com/spaolacci/murmur3 v1.1.0 // indirect
···
86
105
github.com/subosito/gotenv v1.6.0 // indirect
87
106
github.com/tdewolff/parse/v2 v2.7.15 // indirect
88
107
github.com/urfave/cli/v2 v2.27.5 // indirect
108
+
github.com/valyala/bytebufferpool v1.0.0 // indirect
109
+
github.com/valyala/fasttemplate v1.2.2 // indirect
89
110
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
111
+
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
112
+
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
90
113
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
91
114
go.opentelemetry.io/otel v1.29.0 // indirect
92
115
go.opentelemetry.io/otel/metric v1.29.0 // indirect
···
96
119
go.uber.org/zap v1.26.0 // indirect
97
120
golang.org/x/crypto v0.32.0 // indirect
98
121
golang.org/x/mod v0.21.0 // indirect
122
+
golang.org/x/net v0.33.0 // indirect
99
123
golang.org/x/sys v0.29.0 // indirect
100
124
golang.org/x/text v0.21.0 // indirect
101
125
google.golang.org/protobuf v1.36.1 // indirect
102
126
gopkg.in/yaml.v3 v3.0.1 // indirect
127
+
gorm.io/driver/sqlite v1.5.7 // indirect
128
+
gorm.io/gorm v1.25.9 // indirect
103
129
lukechampine.com/blake3 v1.2.1 // indirect
104
130
)
105
131
+61
-4
go.sum
+61
-4
go.sum
···
11
11
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
12
12
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
13
13
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
14
+
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
15
+
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
14
16
github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps=
15
17
github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU=
16
18
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
···
37
39
github.com/bep/overlayfs v0.9.2/go.mod h1:aYY9W7aXQsGcA7V9x/pzeR8LjEgIxbtisZm8Q7zPz40=
38
40
github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI=
39
41
github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0=
40
-
github.com/bluesky-social/indigo v0.0.0-20250506174012-7075cf22f63e h1:yEW1njmALj7i1AjLhq6Lsxts48JUCTT+wpM9m7GNLVY=
41
-
github.com/bluesky-social/indigo v0.0.0-20250506174012-7075cf22f63e/go.mod h1:ovyxp8AMO1Hoe838vMJUbqHTZaAR8ABM3g3TXu+A5Ng=
42
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU=
43
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8=
42
44
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
43
45
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
44
46
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
···
121
123
github.com/gohugoio/locales v0.14.0/go.mod h1:ip8cCAv/cnmVLzzXtiTpPwgJ4xhKZranqNqtoIu0b/4=
122
124
github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo=
123
125
github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0=
124
-
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
125
-
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
126
+
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
127
+
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
126
128
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
127
129
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
128
130
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
···
138
140
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
139
141
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
140
142
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
143
+
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
141
144
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
142
145
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
143
146
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
147
+
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
148
+
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
144
149
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
145
150
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
146
151
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
147
152
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
148
153
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
154
+
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
155
+
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
156
+
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
157
+
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
158
+
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
159
+
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
149
160
github.com/haileyok/atproto-oauth-golang v0.0.2 h1:61KPkLB615LQXR2f5x1v3sf6vPe6dOXqNpTYCgZ0Fz8=
150
161
github.com/haileyok/atproto-oauth-golang v0.0.2/go.mod h1:jcZ4GCjo5I5RuE/RsAXg1/b6udw7R4W+2rb/cGyTDK8=
151
162
github.com/hairyhenderson/go-codeowners v0.5.0 h1:dpQB+hVHiRc2VVvc2BHxkuM+tmu9Qej/as3apqUbsWc=
···
199
210
github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4=
200
211
github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
201
212
github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
213
+
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
214
+
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
215
+
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
216
+
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
202
217
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
203
218
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
204
219
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
···
221
236
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
222
237
github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U=
223
238
github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
239
+
github.com/labstack/echo-contrib v0.17.2 h1:K1zivqmtcC70X9VdBFdLomjPDEVHlrcAObqmuFj1c6w=
240
+
github.com/labstack/echo-contrib v0.17.2/go.mod h1:NeDh3PX7j/u+jR4iuDt1zHmWZSCz9c/p9mxXcDpyS8E=
241
+
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
242
+
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
243
+
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
244
+
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
224
245
github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
225
246
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
226
247
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
···
251
272
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
252
273
github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU=
253
274
github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
275
+
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
276
+
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
254
277
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
255
278
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
256
279
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE=
···
273
296
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
274
297
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
275
298
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
299
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
300
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
276
301
github.com/niklasfasching/go-org v1.7.0 h1:vyMdcMWWTe/XmANk19F4k8XGBYg0GQ/gJGMimOjGMek=
277
302
github.com/niklasfasching/go-org v1.7.0/go.mod h1:WuVm4d45oePiE0eX25GqTDQIt/qPW1T9DGkRscqLW5o=
278
303
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
···
297
322
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
298
323
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0=
299
324
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
325
+
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
326
+
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
327
+
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
328
+
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
300
329
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
330
+
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
331
+
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
332
+
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
333
+
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
334
+
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
335
+
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
336
+
github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ=
337
+
github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s=
338
+
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
339
+
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
340
+
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
341
+
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
301
342
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
302
343
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
303
344
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
···
309
350
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
310
351
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
311
352
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
353
+
github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc=
354
+
github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU=
355
+
github.com/samber/slog-echo v1.15.1 h1:mzeQNPYPxmpehIRtgQJRgJMVvrRbZHp5D2maxSljTBw=
356
+
github.com/samber/slog-echo v1.15.1/go.mod h1:K21nbusPmai/MYm8PFactmZoFctkMmkeaTdXXyvhY1c=
312
357
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
313
358
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
314
359
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
···
356
401
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
357
402
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
358
403
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
404
+
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
405
+
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
406
+
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
407
+
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
359
408
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
360
409
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
361
410
github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11 h1:5HZfQkwe0mIfyDmc1Em5GqlNRzcdtlv4HTNmdpt7XH0=
···
372
421
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
373
422
github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
374
423
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
424
+
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
425
+
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
426
+
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
427
+
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
375
428
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
376
429
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
377
430
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
···
537
590
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
538
591
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
539
592
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
593
+
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
594
+
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
595
+
gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8=
596
+
gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
540
597
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
541
598
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
542
599
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+22
-14
lexicons/teal/feed/defs.json
+22
-14
lexicons/teal/feed/defs.json
···
5
5
"defs": {
6
6
"playView": {
7
7
"type": "object",
8
-
"required": ["trackName", "artistNames"],
8
+
"required": ["trackName", "artists"],
9
9
"properties": {
10
10
"trackName": {
11
11
"type": "string",
···
26
26
"type": "integer",
27
27
"description": "The length of the track in seconds"
28
28
},
29
-
"artistNames": {
30
-
"type": "array",
31
-
"items": {
32
-
"type": "string",
33
-
"minLength": 1,
34
-
"maxLength": 256,
35
-
"maxGraphemes": 2560
36
-
},
37
-
"description": "Array of artist names in order of original appearance."
38
-
},
39
-
"artistMbIds": {
29
+
"artists": {
40
30
"type": "array",
41
31
"items": {
42
-
"type": "string"
32
+
"type": "ref",
33
+
"ref": "#artist"
43
34
},
44
-
"description": "Array of Musicbrainz artist IDs"
35
+
"description": "Array of artists in order of original appearance."
45
36
},
46
37
"releaseName": {
47
38
"type": "string",
···
75
66
"type": "string",
76
67
"format": "datetime",
77
68
"description": "The unix timestamp of when the track was played"
69
+
}
70
+
}
71
+
},
72
+
"artist": {
73
+
"type": "object",
74
+
"required": ["artistName"],
75
+
"properties": {
76
+
"artistName": {
77
+
"type": "string",
78
+
"minLength": 1,
79
+
"maxLength": 256,
80
+
"maxGraphemes": 2560,
81
+
"description": "The name of the artist"
82
+
},
83
+
"artistMbId": {
84
+
"type": "string",
85
+
"description": "The Musicbrainz ID of the artist"
78
86
}
79
87
}
80
88
}
+11
-3
lexicons/teal/feed/play.json
+11
-3
lexicons/teal/feed/play.json
···
8
8
"key": "tid",
9
9
"record": {
10
10
"type": "object",
11
-
"required": ["trackName", "artistNames"],
11
+
"required": ["trackName"],
12
12
"properties": {
13
13
"trackName": {
14
14
"type": "string",
···
38
38
"maxLength": 256,
39
39
"maxGraphemes": 2560
40
40
},
41
-
"description": "Array of artist names in order of original appearance."
41
+
"description": "Array of artist names in order of original appearance. Prefer using 'artists'."
42
42
},
43
43
"artistMbIds": {
44
44
"type": "array",
45
45
"items": {
46
46
"type": "string"
47
47
},
48
-
"description": "Array of Musicbrainz artist IDs"
48
+
"description": "Array of Musicbrainz artist IDs. Prefer using 'artists'."
49
+
},
50
+
"artists": {
51
+
"type": "array",
52
+
"items": {
53
+
"type": "ref",
54
+
"ref": "fm.teal.alpha.feed.defs#artist"
55
+
},
56
+
"description": "Array of artists in order of original appearance."
49
57
},
50
58
"releaseName": {
51
59
"type": "string",
+122
models/listenbrainz.go
+122
models/listenbrainz.go
···
1
+
package models
2
+
3
+
import "time"
4
+
5
+
// ListenBrainzSubmission represents the top-level submission format
6
+
type ListenBrainzSubmission struct {
7
+
ListenType string `json:"listen_type"`
8
+
Payload []ListenBrainzPayload `json:"payload"`
9
+
}
10
+
11
+
// ListenBrainzPayload represents individual listen data
12
+
type ListenBrainzPayload struct {
13
+
ListenedAt *int64 `json:"listened_at,omitempty"`
14
+
TrackMetadata ListenBrainzTrackMetadata `json:"track_metadata"`
15
+
}
16
+
17
+
// ListenBrainzTrackMetadata contains the track information
18
+
type ListenBrainzTrackMetadata struct {
19
+
ArtistName string `json:"artist_name"`
20
+
TrackName string `json:"track_name"`
21
+
ReleaseName *string `json:"release_name,omitempty"`
22
+
AdditionalInfo *ListenBrainzAdditionalInfo `json:"additional_info,omitempty"`
23
+
}
24
+
25
+
// ListenBrainzAdditionalInfo contains optional metadata
26
+
type ListenBrainzAdditionalInfo struct {
27
+
MediaPlayer *string `json:"media_player,omitempty"`
28
+
SubmissionClient *string `json:"submission_client,omitempty"`
29
+
SubmissionClientVersion *string `json:"submission_client_version,omitempty"`
30
+
RecordingMBID *string `json:"recording_mbid,omitempty"`
31
+
ArtistMBIDs []string `json:"artist_mbids,omitempty"`
32
+
ReleaseMBID *string `json:"release_mbid,omitempty"`
33
+
ReleaseGroupMBID *string `json:"release_group_mbid,omitempty"`
34
+
TrackMBID *string `json:"track_mbid,omitempty"`
35
+
WorkMBIDs []string `json:"work_mbids,omitempty"`
36
+
Tags []string `json:"tags,omitempty"`
37
+
DurationMs *int64 `json:"duration_ms,omitempty"`
38
+
SpotifyID *string `json:"spotify_id,omitempty"`
39
+
ISRC *string `json:"isrc,omitempty"`
40
+
TrackNumber *int `json:"tracknumber,omitempty"`
41
+
DiscNumber *int `json:"discnumber,omitempty"`
42
+
MusicService *string `json:"music_service,omitempty"`
43
+
MusicServiceName *string `json:"music_service_name,omitempty"`
44
+
OriginURL *string `json:"origin_url,omitempty"`
45
+
LastFMTrackURL *string `json:"lastfm_track_url,omitempty"`
46
+
YoutubeID *string `json:"youtube_id,omitempty"`
47
+
}
48
+
49
+
// ConvertToTrack converts ListenBrainz format to internal Track format
50
+
func (lbp *ListenBrainzPayload) ConvertToTrack(userID int64) Track {
51
+
track := Track{
52
+
Name: lbp.TrackMetadata.TrackName,
53
+
Artist: []Artist{{Name: lbp.TrackMetadata.ArtistName}},
54
+
}
55
+
56
+
// Set timestamp
57
+
if lbp.ListenedAt != nil {
58
+
track.Timestamp = time.Unix(*lbp.ListenedAt, 0)
59
+
} else {
60
+
track.Timestamp = time.Now()
61
+
}
62
+
63
+
// Set album/release name
64
+
if lbp.TrackMetadata.ReleaseName != nil {
65
+
track.Album = *lbp.TrackMetadata.ReleaseName
66
+
}
67
+
68
+
// Handle additional info if present
69
+
if info := lbp.TrackMetadata.AdditionalInfo; info != nil {
70
+
// Set MBIDs
71
+
if info.RecordingMBID != nil {
72
+
track.RecordingMBID = info.RecordingMBID
73
+
}
74
+
if info.ReleaseMBID != nil {
75
+
track.ReleaseMBID = info.ReleaseMBID
76
+
}
77
+
78
+
// Set duration
79
+
if info.DurationMs != nil {
80
+
track.DurationMs = *info.DurationMs
81
+
}
82
+
83
+
// Set ISRC
84
+
if info.ISRC != nil {
85
+
track.ISRC = *info.ISRC
86
+
}
87
+
88
+
// Handle multiple artists from MBIDs
89
+
if len(info.ArtistMBIDs) > 0 {
90
+
artists := make([]Artist, len(info.ArtistMBIDs))
91
+
for i, mbid := range info.ArtistMBIDs {
92
+
artists[i] = Artist{
93
+
Name: lbp.TrackMetadata.ArtistName, // Use main artist name
94
+
MBID: &mbid,
95
+
}
96
+
}
97
+
track.Artist = artists
98
+
}
99
+
100
+
// Set service information
101
+
if info.MusicService != nil {
102
+
track.ServiceBaseUrl = *info.MusicService
103
+
}
104
+
if info.OriginURL != nil {
105
+
track.URL = *info.OriginURL
106
+
}
107
+
if info.SpotifyID != nil {
108
+
track.URL = "https://open.spotify.com/track/" + *info.SpotifyID
109
+
track.ServiceBaseUrl = "spotify"
110
+
}
111
+
}
112
+
113
+
// Default service if not set
114
+
if track.ServiceBaseUrl == "" {
115
+
track.ServiceBaseUrl = "listenbrainz"
116
+
}
117
+
118
+
// Mark as stamped since it came from external submission
119
+
track.HasStamped = true
120
+
121
+
return track
122
+
}
+5
-5
models/track.go
+5
-5
models/track.go
···
7
7
PlayID int64 `json:"playId"`
8
8
Name string `json:"name"`
9
9
// analogous to "track"
10
-
RecordingMBID string `json:"trackMBID"`
10
+
RecordingMBID *string `json:"trackMBID,omitempty"`
11
11
Artist []Artist `json:"artist"`
12
12
Album string `json:"album"`
13
13
// analogous to "album"
14
-
ReleaseMBID string `json:"releaseMBID"`
14
+
ReleaseMBID *string `json:"releaseMBID,omitempty"`
15
15
URL string `json:"url"`
16
16
Timestamp time.Time `json:"timestamp"`
17
17
DurationMs int64 `json:"durationMs"`
···
22
22
}
23
23
24
24
type Artist struct {
25
-
Name string `json:"name"`
26
-
ID string `json:"id"`
27
-
MBID string `json:"mbid"`
25
+
Name string `json:"name"`
26
+
ID string `json:"id"`
27
+
MBID *string `json:"mbid,omitempty"`
28
28
}
+7
-4
models/user.go
+7
-4
models/user.go
···
18
18
LastFMUsername *string
19
19
20
20
// atp info
21
-
ATProtoDID *string
22
-
ATProtoAccessToken *string
23
-
ATProtoRefreshToken *string
24
-
ATProtoTokenExpiry *time.Time
21
+
ATProtoDID *string
22
+
//This is meant to only be used by the automated music stamping service. If the user ever does an
23
+
//atproto action from the web ui use the atproto session id for the logged-in session
24
+
MostRecentAtProtoSessionID *string
25
+
//ATProtoAccessToken *string
26
+
//ATProtoRefreshToken *string
27
+
//ATProtoTokenExpiry *time.Time
25
28
26
29
CreatedAt time.Time
27
30
UpdatedAt time.Time
+104
-134
oauth/atproto/atproto.go
+104
-134
oauth/atproto/atproto.go
···
3
3
import (
4
4
"context"
5
5
"fmt"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
8
+
_ "github.com/bluesky-social/indigo/atproto/auth/oauth"
9
+
"github.com/bluesky-social/indigo/atproto/client"
10
+
"github.com/bluesky-social/indigo/atproto/crypto"
11
+
"github.com/bluesky-social/indigo/atproto/syntax"
12
+
"github.com/teal-fm/piper/db"
13
+
14
+
"github.com/teal-fm/piper/session"
15
+
6
16
"log"
7
17
"net/http"
8
18
"net/url"
9
-
10
-
oauth "github.com/haileyok/atproto-oauth-golang"
11
-
"github.com/haileyok/atproto-oauth-golang/helpers"
12
-
"github.com/lestrrat-go/jwx/v2/jwk"
13
-
"github.com/teal-fm/piper/db"
14
-
"github.com/teal-fm/piper/models"
19
+
"os"
20
+
"slices"
15
21
)
16
22
17
23
type ATprotoAuthService struct {
18
-
client *oauth.Client
19
-
jwks jwk.Key
20
-
DB *db.DB
21
-
clientId string
22
-
callbackUrl string
23
-
xrpc *oauth.XrpcClient
24
+
clientApp *oauth.ClientApp
25
+
DB *db.DB
26
+
sessionManager *session.SessionManager
27
+
clientId string
28
+
callbackUrl string
29
+
logger *log.Logger
24
30
}
25
31
26
-
func NewATprotoAuthService(db *db.DB, jwks jwk.Key, clientId string, callbackUrl string) (*ATprotoAuthService, error) {
32
+
func NewATprotoAuthService(database *db.DB, sessionManager *session.SessionManager, clientSecretKey string, clientId string, callbackUrl string, clientSecretId string) (*ATprotoAuthService, error) {
27
33
fmt.Println(clientId, callbackUrl)
28
-
cli, err := oauth.NewClient(oauth.ClientArgs{
29
-
ClientJwk: jwks,
30
-
ClientId: clientId,
31
-
RedirectUri: callbackUrl,
32
-
})
34
+
35
+
scopes := []string{"atproto", "repo:fm.teal.alpha.feed.play", "repo:fm.teal.alpha.actor.status"}
36
+
37
+
var config oauth.ClientConfig
38
+
config = oauth.NewPublicConfig(clientId, callbackUrl, scopes)
39
+
40
+
priv, err := crypto.ParsePrivateMultibase(clientSecretKey)
33
41
if err != nil {
34
-
return nil, fmt.Errorf("failed to create atproto oauth client: %w", err)
42
+
return nil, err
35
43
}
44
+
if err := config.SetClientSecret(priv, clientSecretId); err != nil {
45
+
return nil, err
46
+
}
47
+
48
+
oauthClient := oauth.NewClientApp(&config, db.NewSqliteATProtoStore(database.DB))
49
+
50
+
logger := log.New(os.Stdout, "ATProto oauth: ", log.LstdFlags|log.Lmsgprefix)
51
+
36
52
svc := &ATprotoAuthService{
37
-
client: cli,
38
-
jwks: jwks,
39
-
callbackUrl: callbackUrl,
40
-
DB: db,
41
-
clientId: clientId,
53
+
clientApp: oauthClient,
54
+
callbackUrl: callbackUrl,
55
+
DB: database,
56
+
sessionManager: sessionManager,
57
+
clientId: clientId,
58
+
logger: logger,
42
59
}
43
-
svc.NewXrpcClient()
44
60
return svc, nil
45
61
}
46
62
47
-
func (a *ATprotoAuthService) GetATProtoClient() (*oauth.Client, error) {
48
-
if a.client != nil {
49
-
return a.client, nil
63
+
func (a *ATprotoAuthService) GetATProtoClient(accountDID string, sessionID string, ctx context.Context) (*client.APIClient, error) {
64
+
did, err := syntax.ParseDID(accountDID)
65
+
if err != nil {
66
+
return nil, err
50
67
}
51
68
52
-
if a.client == nil {
53
-
cli, err := oauth.NewClient(oauth.ClientArgs{
54
-
ClientJwk: a.jwks,
55
-
ClientId: a.clientId,
56
-
RedirectUri: a.callbackUrl,
57
-
})
58
-
if err != nil {
59
-
return nil, fmt.Errorf("failed to create atproto oauth client: %w", err)
60
-
}
61
-
a.client = cli
69
+
oauthSess, err := a.clientApp.ResumeSession(ctx, did, sessionID)
70
+
if err != nil {
71
+
return nil, err
62
72
}
63
73
64
-
return a.client, nil
65
-
}
74
+
return oauthSess.APIClient(), nil
66
75
67
-
func LoadJwks(jwksBytes []byte) (jwk.Key, error) {
68
-
key, err := helpers.ParseJWKFromBytes(jwksBytes)
69
-
if err != nil {
70
-
return nil, fmt.Errorf("failed to parse JWK from bytes: %w", err)
71
-
}
72
-
return key, nil
73
76
}
74
77
75
78
func (a *ATprotoAuthService) HandleLogin(w http.ResponseWriter, r *http.Request) {
76
79
handle := r.URL.Query().Get("handle")
77
80
if handle == "" {
78
-
log.Printf("ATProto Login Error: handle is required")
81
+
a.logger.Printf("ATProto Login Error: handle is required")
79
82
http.Error(w, "handle query parameter is required", http.StatusBadRequest)
80
83
return
81
84
}
82
-
83
-
authUrl, err := a.getLoginUrlAndSaveState(r.Context(), handle)
85
+
ctx := r.Context()
86
+
redirectURL, err := a.clientApp.StartAuthFlow(ctx, handle)
87
+
if err != nil {
88
+
http.Error(w, fmt.Sprintf("Error initiating login: %v", err), http.StatusInternalServerError)
89
+
}
90
+
authUrl, err := url.Parse(redirectURL)
84
91
if err != nil {
85
-
log.Printf("ATProto Login Error: Failed to get login URL for handle %s: %v", handle, err)
86
92
http.Error(w, fmt.Sprintf("Error initiating login: %v", err), http.StatusInternalServerError)
87
-
return
88
93
}
89
94
90
-
log.Printf("ATProto Login: Redirecting user %s to %s", handle, authUrl.String())
95
+
a.logger.Printf("ATProto Login: Redirecting user %s to %s", handle, authUrl.String())
91
96
http.Redirect(w, r, authUrl.String(), http.StatusFound)
92
97
}
93
98
94
-
func (a *ATprotoAuthService) getLoginUrlAndSaveState(ctx context.Context, handle string) (*url.URL, error) {
95
-
scope := "atproto transition:generic"
96
-
// resolve
97
-
ui, err := a.getUserInformation(ctx, handle)
98
-
if err != nil {
99
-
return nil, fmt.Errorf("failed to get user information for %s: %w", handle, err)
100
-
}
101
-
102
-
fmt.Println("user info: ", ui.AuthServer, ui.AuthService)
103
-
104
-
// create a dpop jwk for this session
105
-
k, err := helpers.GenerateKey(nil) // Generate ephemeral DPoP key for this flow
106
-
if err != nil {
107
-
return nil, fmt.Errorf("failed to generate DPoP key: %w", err)
108
-
}
99
+
func (a *ATprotoAuthService) HandleLogout(w http.ResponseWriter, r *http.Request) {
100
+
cookie, err := r.Cookie("session")
109
101
110
-
// Send PAR auth req
111
-
parResp, err := a.client.SendParAuthRequest(ctx, ui.AuthServer, ui.AuthMeta, ui.Handle, scope, k)
112
-
if err != nil {
113
-
return nil, fmt.Errorf("failed PAR request to %s: %w", ui.AuthServer, err)
114
-
}
102
+
if err == nil {
103
+
session, exists := a.sessionManager.GetSession(cookie.Value)
104
+
if !exists {
105
+
http.Redirect(w, r, "/", http.StatusSeeOther)
106
+
return
107
+
}
115
108
116
-
// Save state
117
-
data := &models.ATprotoAuthData{
118
-
State: parResp.State,
119
-
DID: ui.DID,
120
-
PDSUrl: ui.AuthService,
121
-
AuthServerIssuer: ui.AuthMeta.Issuer,
122
-
PKCEVerifier: parResp.PkceVerifier,
123
-
DPoPAuthServerNonce: parResp.DpopAuthserverNonce,
124
-
DPoPPrivateJWK: k,
125
-
}
109
+
dbUser, err := a.DB.GetUserByID(session.UserID)
110
+
if err != nil {
111
+
http.Redirect(w, r, "/", http.StatusSeeOther)
112
+
return
113
+
}
114
+
did, err := syntax.ParseDID(*dbUser.ATProtoDID)
126
115
127
-
// print data
128
-
fmt.Println(data)
116
+
if err != nil {
117
+
a.logger.Printf("Should not happen: %s", err)
118
+
a.sessionManager.ClearSessionCookie(w)
119
+
http.Redirect(w, r, "/", http.StatusSeeOther)
120
+
}
129
121
130
-
err = a.DB.SaveATprotoAuthData(data)
131
-
if err != nil {
132
-
return nil, fmt.Errorf("failed to save ATProto auth data for state %s: %w", parResp.State, err)
122
+
ctx := r.Context()
123
+
err = a.clientApp.Logout(ctx, did, session.ATProtoSessionID)
124
+
if err != nil {
125
+
a.logger.Printf("Error logging the user: %s out: %s", did, err)
126
+
}
127
+
a.sessionManager.DeleteSession(cookie.Value)
133
128
}
134
129
135
-
// Construct authorization URL using the request_uri from PAR response
136
-
authEndpointURL, err := url.Parse(ui.AuthMeta.AuthorizationEndpoint)
137
-
if err != nil {
138
-
return nil, fmt.Errorf("invalid authorization endpoint URL %s: %w", ui.AuthMeta.AuthorizationEndpoint, err)
139
-
}
140
-
q := authEndpointURL.Query()
141
-
q.Set("client_id", a.clientId)
142
-
q.Set("request_uri", parResp.RequestUri)
143
-
q.Set("state", parResp.State)
144
-
authEndpointURL.RawQuery = q.Encode()
130
+
a.sessionManager.ClearSessionCookie(w)
145
131
146
-
return authEndpointURL, nil
132
+
http.Redirect(w, r, "/", http.StatusSeeOther)
147
133
}
148
134
149
135
func (a *ATprotoAuthService) HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error) {
150
-
state := r.URL.Query().Get("state")
151
-
code := r.URL.Query().Get("code")
152
-
issuer := r.URL.Query().Get("iss") // Issuer (auth base URL) is needed for token request
153
-
154
-
if state == "" || code == "" || issuer == "" {
155
-
errMsg := r.URL.Query().Get("error")
156
-
errDesc := r.URL.Query().Get("error_description")
157
-
log.Printf("ATProto Callback Error: Missing parameters. State: '%s', Code: '%s', Issuer: '%s'. Error: '%s', Description: '%s'", state, code, issuer, errMsg, errDesc)
158
-
http.Error(w, fmt.Sprintf("Authorization callback failed: %s (%s). Missing state, code, or issuer.", errMsg, errDesc), http.StatusBadRequest)
159
-
return 0, fmt.Errorf("missing state, code, or issuer")
160
-
}
136
+
ctx := r.Context()
161
137
162
-
// Retrieve saved data using state
163
-
data, err := a.DB.GetATprotoAuthData(state)
138
+
sessData, err := a.clientApp.ProcessCallback(ctx, r.URL.Query())
164
139
if err != nil {
165
-
log.Printf("ATProto Callback Error: Failed to retrieve auth data for state '%s': %v", state, err)
166
-
http.Error(w, "Invalid or expired state.", http.StatusBadRequest)
167
-
return 0, fmt.Errorf("invalid or expired state")
140
+
errMsg := fmt.Errorf("processing OAuth callback: %w", err)
141
+
http.Error(w, errMsg.Error(), http.StatusBadRequest)
142
+
return 0, errMsg
168
143
}
169
144
170
-
// Clean up the temporary auth data now that we've retrieved it
171
-
// defer a.DB.DeleteATprotoAuthData(state) // Consider adding deletion logic
172
-
// if issuers don't match, return an error
173
-
if data.AuthServerIssuer != issuer {
174
-
log.Printf("ATProto Callback Error: Issuer mismatch for state '%s', expected '%s', got '%s'", state, data.AuthServerIssuer, issuer)
175
-
http.Error(w, "Invalid or expired state.", http.StatusBadRequest)
176
-
return 0, fmt.Errorf("issuer mismatch")
145
+
// It's in the example repo and leaving for some debugging cause i've seen different scopes cause issues before
146
+
// so may be some nice debugging info to have
147
+
if !slices.Equal(sessData.Scopes, a.clientApp.Config.Scopes) {
148
+
a.logger.Printf("session auth scopes did not match those requested")
177
149
}
178
150
179
-
resp, err := a.client.InitialTokenRequest(r.Context(), code, issuer, data.PKCEVerifier, data.DPoPAuthServerNonce, data.DPoPPrivateJWK)
151
+
user, err := a.DB.FindOrCreateUserByDID(sessData.AccountDID.String())
180
152
if err != nil {
181
-
log.Printf("ATProto Callback Error: Failed initial token request for state '%s', issuer '%s': %v", state, issuer, err)
182
-
http.Error(w, fmt.Sprintf("Error exchanging code for token: %v", err), http.StatusInternalServerError)
183
-
return 0, fmt.Errorf("failed initial token request")
184
-
}
185
-
186
-
userID, err := a.DB.FindOrCreateUserByDID(data.DID)
187
-
if err != nil {
188
-
log.Printf("ATProto Callback Error: Failed to find or create user for DID %s: %v", data.DID, err)
153
+
a.logger.Printf("ATProto Callback Error: Failed to find or create user for DID %s: %v", sessData.AccountDID.String(), err)
189
154
http.Error(w, "Failed to process user information.", http.StatusInternalServerError)
190
155
return 0, fmt.Errorf("failed to find or create user")
191
156
}
192
157
193
-
err = a.DB.SaveATprotoSession(resp, data.AuthServerIssuer, data.DPoPPrivateJWK, data.PDSUrl)
158
+
//This is piper's session for manging piper, not atproto sessions
159
+
createdSession := a.sessionManager.CreateSession(user.ID, sessData.SessionID)
160
+
a.sessionManager.SetSessionCookie(w, createdSession)
161
+
a.logger.Printf("Created session for user %d via service atproto", user.ATProtoDID)
162
+
163
+
err = a.DB.SetLatestATProtoSessionId(sessData.AccountDID.String(), sessData.SessionID)
194
164
if err != nil {
195
-
log.Printf("ATProto Callback Error: Failed to save ATProto tokens for user %d (DID %s): %v", userID.ID, data.DID, err)
165
+
a.logger.Printf("Failed to set latest atproto session id for user %d: %v", user.ID, err)
196
166
}
197
167
198
-
log.Printf("ATProto Callback Success: User %d (DID: %s) authenticated.", userID.ID, data.DID)
199
-
return userID.ID, nil
168
+
a.logger.Printf("ATProto Callback Success: User %d (DID: %s) authenticated.", user.ID, user.ATProtoDID)
169
+
return user.ID, nil
200
170
}
+26
-30
oauth/atproto/http.go
+26
-30
oauth/atproto/http.go
···
4
4
import (
5
5
"encoding/json"
6
6
"fmt"
7
-
"log"
8
7
"net/http"
9
-
10
-
"github.com/haileyok/atproto-oauth-golang/helpers"
11
8
)
12
9
13
-
func (a *ATprotoAuthService) HandleJwks(w http.ResponseWriter, r *http.Request) {
14
-
pubKey, err := a.jwks.PublicKey()
15
-
if err != nil {
16
-
http.Error(w, fmt.Sprintf("Error getting public key from JWK: %v", err), http.StatusInternalServerError)
17
-
log.Printf("Error getting public key from JWK: %v", err)
18
-
return
19
-
}
10
+
func strPtr(raw string) *string {
11
+
return &raw
12
+
}
20
13
14
+
func (a *ATprotoAuthService) HandleJwks(w http.ResponseWriter, r *http.Request) {
21
15
w.Header().Set("Content-Type", "application/json")
22
-
if err := json.NewEncoder(w).Encode(helpers.CreateJwksResponseObject(pubKey)); err != nil {
23
-
log.Printf("Error encoding JWKS response: %v", err)
16
+
body := a.clientApp.Config.PublicJWKS()
17
+
if err := json.NewEncoder(w).Encode(body); err != nil {
18
+
http.Error(w, err.Error(), http.StatusInternalServerError)
19
+
return
24
20
}
25
21
}
26
22
27
23
func (a *ATprotoAuthService) HandleClientMetadata(w http.ResponseWriter, r *http.Request, serverUrlRoot, serverMetadataUrl, serverCallbackUrl string) {
28
-
metadata := map[string]any{
29
-
"client_id": serverMetadataUrl,
30
-
"client_name": "Piper Telekinesis",
31
-
"client_uri": serverUrlRoot,
32
-
"logo_uri": fmt.Sprintf("%s/logo.png", serverUrlRoot),
33
-
"tos_uri": fmt.Sprintf("%s/tos", serverUrlRoot),
34
-
"policy_url": fmt.Sprintf("%s/policy", serverUrlRoot),
35
-
"redirect_uris": []string{serverCallbackUrl},
36
-
"grant_types": []string{"authorization_code", "refresh_token"},
37
-
"response_types": []string{"code"},
38
-
"application_type": "web",
39
-
"dpop_bound_access_tokens": true,
40
-
"jwks_uri": fmt.Sprintf("%s/oauth/jwks.json", serverUrlRoot),
41
-
"scope": "atproto transition:generic",
42
-
"token_endpoint_auth_method": "private_key_jwt",
43
-
"token_endpoint_auth_signing_alg": "ES256",
24
+
25
+
meta := a.clientApp.Config.ClientMetadata()
26
+
if a.clientApp.Config.IsConfidential() {
27
+
meta.JWKSURI = strPtr(fmt.Sprintf("%s/oauth/jwks.json", serverUrlRoot))
44
28
}
29
+
meta.ClientName = strPtr("Piper Telekinesis")
30
+
meta.ClientURI = strPtr(serverUrlRoot)
31
+
32
+
// internal consistency check
33
+
if err := meta.Validate(a.clientApp.Config.ClientID); err != nil {
34
+
a.logger.Printf("validating client metadata", "err", err)
35
+
http.Error(w, err.Error(), http.StatusInternalServerError)
36
+
return
37
+
}
38
+
45
39
w.Header().Set("Content-Type", "application/json")
46
-
if err := json.NewEncoder(w).Encode(metadata); err != nil {
47
-
log.Printf("Error encoding client metadata: %v", err)
40
+
if err := json.NewEncoder(w).Encode(meta); err != nil {
41
+
http.Error(w, err.Error(), http.StatusInternalServerError)
42
+
return
48
43
}
44
+
49
45
}
+2
-55
oauth/atproto/resolve.go
+2
-55
oauth/atproto/resolve.go
···
12
12
"strings"
13
13
14
14
"github.com/bluesky-social/indigo/atproto/syntax"
15
-
oauth "github.com/haileyok/atproto-oauth-golang"
16
15
)
17
16
18
17
// user information struct
19
18
type UserInformation struct {
20
-
AuthService string `json:"authService"`
21
-
AuthServer string `json:"authServer"`
22
-
AuthMeta *oauth.OauthAuthorizationMetadata `json:"authMeta"`
19
+
AuthService string `json:"authService"`
20
+
AuthServer string `json:"authServer"`
23
21
// do NOT save the current handle permanently!
24
22
Handle string `json:"handle"`
25
23
DID string `json:"did"`
···
32
30
Type string `json:"type"`
33
31
ServiceEndpoint string `json:"serviceEndpoint"`
34
32
} `json:"service"`
35
-
}
36
-
37
-
func (a *ATprotoAuthService) getUserInformation(ctx context.Context, handleOrDid string) (*UserInformation, error) {
38
-
cli := a.client
39
-
40
-
// if we have a did skip this
41
-
did := handleOrDid
42
-
err := error(nil)
43
-
// technically checking SHOULD be more rigorous.
44
-
if !strings.HasPrefix(handleOrDid, "did:") {
45
-
did, err = resolveHandle(ctx, did)
46
-
if err != nil {
47
-
return nil, err
48
-
}
49
-
} else {
50
-
did = handleOrDid
51
-
}
52
-
53
-
doc, err := getIdentityDocument(ctx, did)
54
-
if err != nil {
55
-
return nil, err
56
-
}
57
-
58
-
service, err := getAtprotoPdsService(doc)
59
-
if err != nil {
60
-
return nil, err
61
-
}
62
-
63
-
authserver, err := cli.ResolvePdsAuthServer(ctx, service)
64
-
if err != nil {
65
-
return nil, err
66
-
}
67
-
68
-
authmeta, err := cli.FetchAuthServerMetadata(ctx, authserver)
69
-
if err != nil {
70
-
return nil, err
71
-
}
72
-
73
-
if len(doc.AlsoKnownAs) == 0 {
74
-
return nil, fmt.Errorf("alsoKnownAs is empty, couldn't acquire handle: %w", err)
75
-
76
-
}
77
-
handle := strings.Replace(doc.AlsoKnownAs[0], "at://", "", 1)
78
-
79
-
return &UserInformation{
80
-
AuthService: service,
81
-
AuthServer: authserver,
82
-
AuthMeta: authmeta,
83
-
Handle: handle,
84
-
DID: did,
85
-
}, nil
86
33
}
87
34
88
35
func resolveHandle(ctx context.Context, handle string) (string, error) {
-25
oauth/atproto/xrpc.go
-25
oauth/atproto/xrpc.go
···
1
-
package atproto
2
-
3
-
import (
4
-
"log/slog"
5
-
6
-
oauth "github.com/haileyok/atproto-oauth-golang"
7
-
)
8
-
9
-
func (atp *ATprotoAuthService) NewXrpcClient() {
10
-
atp.xrpc = &oauth.XrpcClient{
11
-
OnDpopPdsNonceChanged: func(did, newNonce string) {
12
-
_, err := atp.DB.Exec("UPDATE users SET atproto_pds_nonce = ? WHERE atproto_did = ?", newNonce, did)
13
-
if err != nil {
14
-
slog.Default().Error("error updating pds nonce", "err", err)
15
-
}
16
-
},
17
-
}
18
-
}
19
-
20
-
func (atp *ATprotoAuthService) GetXrpcClient() *oauth.XrpcClient {
21
-
if atp.xrpc == nil {
22
-
atp.NewXrpcClient()
23
-
}
24
-
return atp.xrpc
25
-
}
+6
-2
oauth/oauth2.go
+6
-2
oauth/oauth2.go
···
86
86
http.Redirect(w, r, authURL, http.StatusSeeOther)
87
87
}
88
88
89
+
func (o *OAuth2Service) HandleLogout(w http.ResponseWriter, r *http.Request) {
90
+
//TODO not implemented yet. not sure what the api call is for this package
91
+
http.Redirect(w, r, "/", http.StatusSeeOther)
92
+
}
93
+
89
94
func (o *OAuth2Service) HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error) {
90
95
state := r.URL.Query().Get("state")
91
96
if state != o.state {
···
123
128
}
124
129
125
130
userId, hasSession := session.GetUserID(r.Context())
126
-
127
131
// store token and get uid
128
-
userID, err := o.tokenReceiver.SetAccessToken(token.AccessToken, userId, hasSession)
132
+
userID, err := o.tokenReceiver.SetAccessToken(token.AccessToken, token.RefreshToken, userId, hasSession)
129
133
if err != nil {
130
134
log.Printf("OAuth2 Callback Info: TokenReceiver did not return a valid user ID for token: %s...", token.AccessToken[:min(10, len(token.AccessToken))])
131
135
}
+28
-19
oauth/oauth_manager.go
+28
-19
oauth/oauth_manager.go
···
6
6
"log"
7
7
"net/http"
8
8
"sync"
9
-
10
-
"github.com/teal-fm/piper/session"
11
9
)
12
10
13
11
// manages multiple oauth client services
14
12
type OAuthServiceManager struct {
15
-
services map[string]AuthService
16
-
sessionManager *session.SessionManager
17
-
mu sync.RWMutex
13
+
services map[string]AuthService
14
+
mu sync.RWMutex
15
+
logger *log.Logger
18
16
}
19
17
20
-
func NewOAuthServiceManager(sessionManager *session.SessionManager) *OAuthServiceManager {
18
+
func NewOAuthServiceManager() *OAuthServiceManager {
21
19
return &OAuthServiceManager{
22
-
services: make(map[string]AuthService),
23
-
sessionManager: sessionManager,
20
+
services: make(map[string]AuthService),
21
+
logger: log.New(log.Writer(), "oauth: ", log.LstdFlags|log.Lmsgprefix),
24
22
}
25
23
}
26
24
···
29
27
m.mu.Lock()
30
28
defer m.mu.Unlock()
31
29
m.services[name] = service
32
-
log.Printf("Registered auth service: %s", name)
30
+
m.logger.Printf("Registered auth service: %s", name)
33
31
}
34
32
35
33
// get an AuthService by registered name
···
51
49
return
52
50
}
53
51
54
-
log.Printf("Auth service '%s' not found for login request", serviceName)
52
+
m.logger.Printf("Auth service '%s' not found for login request", serviceName)
53
+
http.Error(w, fmt.Sprintf("Auth service '%s' not found", serviceName), http.StatusNotFound)
54
+
}
55
+
}
56
+
57
+
func (m *OAuthServiceManager) HandleLogout(serviceName string) http.HandlerFunc {
58
+
return func(w http.ResponseWriter, r *http.Request) {
59
+
m.mu.RLock()
60
+
service, exists := m.services[serviceName]
61
+
m.mu.RUnlock()
62
+
63
+
if exists {
64
+
service.HandleLogout(w, r)
65
+
return
66
+
}
67
+
68
+
m.logger.Printf("Auth service '%s' not found for login request", serviceName)
55
69
http.Error(w, fmt.Sprintf("Auth service '%s' not found", serviceName), http.StatusNotFound)
56
70
}
57
71
}
···
62
76
service, exists := m.services[serviceName]
63
77
m.mu.RUnlock()
64
78
65
-
log.Printf("Logging in with service %s", serviceName)
79
+
m.logger.Printf("Logging in with service %s", serviceName)
66
80
67
81
if !exists {
68
-
log.Printf("Auth service '%s' not found for callback request", serviceName)
82
+
m.logger.Printf("Auth service '%s' not found for callback request", serviceName)
69
83
http.Error(w, fmt.Sprintf("OAuth service '%s' not found", serviceName), http.StatusNotFound)
70
84
return
71
85
}
···
73
87
userID, err := service.HandleCallback(w, r)
74
88
75
89
if err != nil {
76
-
log.Printf("Error handling callback for service '%s': %v", serviceName, err)
90
+
m.logger.Printf("Error handling callback for service '%s': %v", serviceName, err)
77
91
http.Error(w, fmt.Sprintf("Error handling callback for service '%s'", serviceName), http.StatusInternalServerError)
78
92
return
79
93
}
80
94
81
95
if userID > 0 {
82
-
session := m.sessionManager.CreateSession(userID)
83
-
84
-
m.sessionManager.SetSessionCookie(w, session)
85
-
86
-
log.Printf("Created session for user %d via service %s", userID, serviceName)
87
96
88
97
http.Redirect(w, r, "/", http.StatusSeeOther)
89
98
} else {
90
-
log.Printf("Callback for service '%s' did not result in a valid user ID.", serviceName)
99
+
m.logger.Printf("Callback for service '%s' did not result in a valid user ID.", serviceName)
91
100
// todo: redirect to an error page
92
101
// right now this just redirects home but we don't want this behaviour ideally
93
102
http.Redirect(w, r, "/", http.StatusSeeOther)
+3
-1
oauth/service.go
+3
-1
oauth/service.go
···
10
10
// handles the callback for the provider. is responsible for inserting
11
11
// sessions in the db
12
12
HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error)
13
+
14
+
HandleLogout(w http.ResponseWriter, r *http.Request)
13
15
}
14
16
15
17
// optional but recommended
16
18
type TokenReceiver interface {
17
19
// stores the access token in the db
18
20
// if there is a session, will associate the token with the session
19
-
SetAccessToken(token string, currentId int64, hasSession bool) (int64, error)
21
+
SetAccessToken(token string, refreshToken string, currentId int64, hasSession bool) (int64, error)
20
22
}
+35
pages/cache.go
+35
pages/cache.go
···
1
+
package pages
2
+
3
+
import "sync"
4
+
5
+
// Cache for pages
6
+
7
+
type TmplCache[K comparable, V any] struct {
8
+
data map[K]V
9
+
mutex sync.RWMutex
10
+
}
11
+
12
+
func NewTmplCache[K comparable, V any]() *TmplCache[K, V] {
13
+
return &TmplCache[K, V]{
14
+
data: make(map[K]V),
15
+
}
16
+
}
17
+
18
+
func (c *TmplCache[K, V]) Get(key K) (V, bool) {
19
+
c.mutex.RLock()
20
+
defer c.mutex.RUnlock()
21
+
val, exists := c.data[key]
22
+
return val, exists
23
+
}
24
+
25
+
func (c *TmplCache[K, V]) Set(key K, value V) {
26
+
c.mutex.Lock()
27
+
defer c.mutex.Unlock()
28
+
c.data[key] = value
29
+
}
30
+
31
+
func (c *TmplCache[K, V]) Size() int {
32
+
c.mutex.RLock()
33
+
defer c.mutex.RUnlock()
34
+
return len(c.data)
35
+
}
+160
pages/pages.go
+160
pages/pages.go
···
1
+
package pages
2
+
3
+
// Helpers to load gohtml templates and render them
4
+
// forked and inspired from tangled's implementation
5
+
//https://tangled.org/@tangled.org/core/blob/master/appview/pages/pages.go
6
+
7
+
import (
8
+
"embed"
9
+
"html/template"
10
+
"io"
11
+
"io/fs"
12
+
"net/http"
13
+
"strings"
14
+
"time"
15
+
)
16
+
17
+
//go:embed templates/* static/*
18
+
var Files embed.FS
19
+
20
+
type Pages struct {
21
+
cache *TmplCache[string, *template.Template]
22
+
templateDir string // Path to templates on disk for dev mode
23
+
embedFS fs.FS
24
+
}
25
+
26
+
func NewPages() *Pages {
27
+
return &Pages{
28
+
cache: NewTmplCache[string, *template.Template](),
29
+
embedFS: Files,
30
+
}
31
+
}
32
+
33
+
func (p *Pages) fragmentPaths() ([]string, error) {
34
+
var fragmentPaths []string
35
+
err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
36
+
if err != nil {
37
+
return err
38
+
}
39
+
if d.IsDir() {
40
+
return nil
41
+
}
42
+
if !strings.HasSuffix(path, ".gohtml") {
43
+
return nil
44
+
}
45
+
fragmentPaths = append(fragmentPaths, path)
46
+
return nil
47
+
})
48
+
if err != nil {
49
+
return nil, err
50
+
}
51
+
52
+
return fragmentPaths, nil
53
+
}
54
+
55
+
func (p *Pages) pathToName(s string) string {
56
+
return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".gohtml")
57
+
}
58
+
59
+
// reverse of pathToName
60
+
func (p *Pages) nameToPath(s string) string {
61
+
return "templates/" + s + ".gohtml"
62
+
}
63
+
64
+
// parse without memoization
65
+
func (p *Pages) rawParse(stack ...string) (*template.Template, error) {
66
+
paths, err := p.fragmentPaths()
67
+
if err != nil {
68
+
return nil, err
69
+
}
70
+
for _, s := range stack {
71
+
paths = append(paths, p.nameToPath(s))
72
+
}
73
+
74
+
funcs := p.funcMap()
75
+
top := stack[len(stack)-1]
76
+
parsed, err := template.New(top).
77
+
Funcs(funcs).
78
+
ParseFS(p.embedFS, paths...)
79
+
if err != nil {
80
+
return nil, err
81
+
}
82
+
83
+
return parsed, nil
84
+
}
85
+
86
+
func (p *Pages) parse(stack ...string) (*template.Template, error) {
87
+
key := strings.Join(stack, "|")
88
+
89
+
if cached, exists := p.cache.Get(key); exists {
90
+
return cached, nil
91
+
}
92
+
93
+
result, err := p.rawParse(stack...)
94
+
if err != nil {
95
+
return nil, err
96
+
}
97
+
98
+
p.cache.Set(key, result)
99
+
return result, nil
100
+
}
101
+
102
+
func (p *Pages) funcMap() template.FuncMap {
103
+
return template.FuncMap{
104
+
"formatTime": func(t time.Time) string {
105
+
if t.IsZero() {
106
+
return "N/A"
107
+
}
108
+
return t.Format("Jan 02, 2006 15:04")
109
+
},
110
+
}
111
+
}
112
+
113
+
func (p *Pages) parseBase(top string) (*template.Template, error) {
114
+
stack := []string{
115
+
"layouts/base",
116
+
top,
117
+
}
118
+
return p.parse(stack...)
119
+
}
120
+
121
+
func (p *Pages) Static() http.Handler {
122
+
123
+
sub, err := fs.Sub(Files, "static")
124
+
if err != nil {
125
+
panic(err)
126
+
}
127
+
128
+
return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
129
+
}
130
+
131
+
func Cache(h http.Handler) http.Handler {
132
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
133
+
path := strings.Split(r.URL.Path, "?")[0]
134
+
// We may want to change these, just took what tangled has and allows browser side caching
135
+
if strings.HasSuffix(path, ".css") {
136
+
// on day for css files
137
+
w.Header().Set("Cache-Control", "public, max-age=86400")
138
+
} else {
139
+
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
140
+
}
141
+
h.ServeHTTP(w, r)
142
+
})
143
+
}
144
+
145
+
// Execute What loads and renders the HTML page/
146
+
func (p *Pages) Execute(name string, w io.Writer, params any) error {
147
+
tpl, err := p.parseBase(name)
148
+
if err != nil {
149
+
return err
150
+
}
151
+
152
+
return tpl.ExecuteTemplate(w, "layouts/base", params)
153
+
}
154
+
155
+
// Shared view/template params
156
+
157
+
type NavBar struct {
158
+
IsLoggedIn bool
159
+
LastFMUsername string
160
+
}
+1
pages/static/base.css
+1
pages/static/base.css
···
1
+
@import "tailwindcss";
+531
pages/static/main.css
+531
pages/static/main.css
···
1
+
/*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */
2
+
@layer properties;
3
+
@layer theme, base, components, utilities;
4
+
@layer theme {
5
+
:root, :host {
6
+
--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
7
+
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
8
+
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
9
+
"Courier New", monospace;
10
+
--color-gray-100: oklch(96.7% 0.003 264.542);
11
+
--color-gray-200: oklch(92.8% 0.006 264.531);
12
+
--color-gray-300: oklch(87.2% 0.01 258.338);
13
+
--color-gray-600: oklch(44.6% 0.03 256.802);
14
+
--color-white: #fff;
15
+
--spacing: 0.25rem;
16
+
--text-lg: 1.125rem;
17
+
--text-lg--line-height: calc(1.75 / 1.125);
18
+
--text-xl: 1.25rem;
19
+
--text-xl--line-height: calc(1.75 / 1.25);
20
+
--font-weight-semibold: 600;
21
+
--font-weight-bold: 700;
22
+
--leading-relaxed: 1.625;
23
+
--radius-lg: 0.5rem;
24
+
--default-font-family: var(--font-sans);
25
+
--default-mono-font-family: var(--font-mono);
26
+
}
27
+
}
28
+
@layer base {
29
+
*, ::after, ::before, ::backdrop, ::file-selector-button {
30
+
box-sizing: border-box;
31
+
margin: 0;
32
+
padding: 0;
33
+
border: 0 solid;
34
+
}
35
+
html, :host {
36
+
line-height: 1.5;
37
+
-webkit-text-size-adjust: 100%;
38
+
tab-size: 4;
39
+
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
40
+
font-feature-settings: var(--default-font-feature-settings, normal);
41
+
font-variation-settings: var(--default-font-variation-settings, normal);
42
+
-webkit-tap-highlight-color: transparent;
43
+
}
44
+
hr {
45
+
height: 0;
46
+
color: inherit;
47
+
border-top-width: 1px;
48
+
}
49
+
abbr:where([title]) {
50
+
-webkit-text-decoration: underline dotted;
51
+
text-decoration: underline dotted;
52
+
}
53
+
h1, h2, h3, h4, h5, h6 {
54
+
font-size: inherit;
55
+
font-weight: inherit;
56
+
}
57
+
a {
58
+
color: inherit;
59
+
-webkit-text-decoration: inherit;
60
+
text-decoration: inherit;
61
+
}
62
+
b, strong {
63
+
font-weight: bolder;
64
+
}
65
+
code, kbd, samp, pre {
66
+
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
67
+
font-feature-settings: var(--default-mono-font-feature-settings, normal);
68
+
font-variation-settings: var(--default-mono-font-variation-settings, normal);
69
+
font-size: 1em;
70
+
}
71
+
small {
72
+
font-size: 80%;
73
+
}
74
+
sub, sup {
75
+
font-size: 75%;
76
+
line-height: 0;
77
+
position: relative;
78
+
vertical-align: baseline;
79
+
}
80
+
sub {
81
+
bottom: -0.25em;
82
+
}
83
+
sup {
84
+
top: -0.5em;
85
+
}
86
+
table {
87
+
text-indent: 0;
88
+
border-color: inherit;
89
+
border-collapse: collapse;
90
+
}
91
+
:-moz-focusring {
92
+
outline: auto;
93
+
}
94
+
progress {
95
+
vertical-align: baseline;
96
+
}
97
+
summary {
98
+
display: list-item;
99
+
}
100
+
ol, ul, menu {
101
+
list-style: none;
102
+
}
103
+
img, svg, video, canvas, audio, iframe, embed, object {
104
+
display: block;
105
+
vertical-align: middle;
106
+
}
107
+
img, video {
108
+
max-width: 100%;
109
+
height: auto;
110
+
}
111
+
button, input, select, optgroup, textarea, ::file-selector-button {
112
+
font: inherit;
113
+
font-feature-settings: inherit;
114
+
font-variation-settings: inherit;
115
+
letter-spacing: inherit;
116
+
color: inherit;
117
+
border-radius: 0;
118
+
background-color: transparent;
119
+
opacity: 1;
120
+
}
121
+
:where(select:is([multiple], [size])) optgroup {
122
+
font-weight: bolder;
123
+
}
124
+
:where(select:is([multiple], [size])) optgroup option {
125
+
padding-inline-start: 20px;
126
+
}
127
+
::file-selector-button {
128
+
margin-inline-end: 4px;
129
+
}
130
+
::placeholder {
131
+
opacity: 1;
132
+
}
133
+
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
134
+
::placeholder {
135
+
color: currentcolor;
136
+
@supports (color: color-mix(in lab, red, red)) {
137
+
color: color-mix(in oklab, currentcolor 50%, transparent);
138
+
}
139
+
}
140
+
}
141
+
textarea {
142
+
resize: vertical;
143
+
}
144
+
::-webkit-search-decoration {
145
+
-webkit-appearance: none;
146
+
}
147
+
::-webkit-date-and-time-value {
148
+
min-height: 1lh;
149
+
text-align: inherit;
150
+
}
151
+
::-webkit-datetime-edit {
152
+
display: inline-flex;
153
+
}
154
+
::-webkit-datetime-edit-fields-wrapper {
155
+
padding: 0;
156
+
}
157
+
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
158
+
padding-block: 0;
159
+
}
160
+
::-webkit-calendar-picker-indicator {
161
+
line-height: 1;
162
+
}
163
+
:-moz-ui-invalid {
164
+
box-shadow: none;
165
+
}
166
+
button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
167
+
appearance: button;
168
+
}
169
+
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
170
+
height: auto;
171
+
}
172
+
[hidden]:where(:not([hidden="until-found"])) {
173
+
display: none !important;
174
+
}
175
+
}
176
+
@layer utilities {
177
+
.absolute {
178
+
position: absolute;
179
+
}
180
+
.relative {
181
+
position: relative;
182
+
}
183
+
.static {
184
+
position: static;
185
+
}
186
+
.sticky {
187
+
position: sticky;
188
+
}
189
+
.container {
190
+
width: 100%;
191
+
@media (width >= 40rem) {
192
+
max-width: 40rem;
193
+
}
194
+
@media (width >= 48rem) {
195
+
max-width: 48rem;
196
+
}
197
+
@media (width >= 64rem) {
198
+
max-width: 64rem;
199
+
}
200
+
@media (width >= 80rem) {
201
+
max-width: 80rem;
202
+
}
203
+
@media (width >= 96rem) {
204
+
max-width: 96rem;
205
+
}
206
+
}
207
+
.mx-auto {
208
+
margin-inline: auto;
209
+
}
210
+
.my-5 {
211
+
margin-block: calc(var(--spacing) * 5);
212
+
}
213
+
.mt-1 {
214
+
margin-top: calc(var(--spacing) * 1);
215
+
}
216
+
.mt-3 {
217
+
margin-top: calc(var(--spacing) * 3);
218
+
}
219
+
.mb-1 {
220
+
margin-bottom: calc(var(--spacing) * 1);
221
+
}
222
+
.mb-2 {
223
+
margin-bottom: calc(var(--spacing) * 2);
224
+
}
225
+
.mb-3 {
226
+
margin-bottom: calc(var(--spacing) * 3);
227
+
}
228
+
.mb-4 {
229
+
margin-bottom: calc(var(--spacing) * 4);
230
+
}
231
+
.mb-5 {
232
+
margin-bottom: calc(var(--spacing) * 5);
233
+
}
234
+
.block {
235
+
display: block;
236
+
}
237
+
.contents {
238
+
display: contents;
239
+
}
240
+
.flex {
241
+
display: flex;
242
+
}
243
+
.hidden {
244
+
display: none;
245
+
}
246
+
.table {
247
+
display: table;
248
+
}
249
+
.w-\[95\%\] {
250
+
width: 95%;
251
+
}
252
+
.w-full {
253
+
width: 100%;
254
+
}
255
+
.max-w-\[600px\] {
256
+
max-width: 600px;
257
+
}
258
+
.max-w-\[800px\] {
259
+
max-width: 800px;
260
+
}
261
+
.border-collapse {
262
+
border-collapse: collapse;
263
+
}
264
+
.transform {
265
+
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
266
+
}
267
+
.cursor-pointer {
268
+
cursor: pointer;
269
+
}
270
+
.list-disc {
271
+
list-style-type: disc;
272
+
}
273
+
.flex-wrap {
274
+
flex-wrap: wrap;
275
+
}
276
+
.space-y-2 {
277
+
:where(& > :not(:last-child)) {
278
+
--tw-space-y-reverse: 0;
279
+
margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));
280
+
margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
281
+
}
282
+
}
283
+
.gap-x-4 {
284
+
column-gap: calc(var(--spacing) * 4);
285
+
}
286
+
.gap-y-1 {
287
+
row-gap: calc(var(--spacing) * 1);
288
+
}
289
+
.rounded {
290
+
border-radius: 0.25rem;
291
+
}
292
+
.rounded-lg {
293
+
border-radius: var(--radius-lg);
294
+
}
295
+
.border {
296
+
border-style: var(--tw-border-style);
297
+
border-width: 1px;
298
+
}
299
+
.border-b {
300
+
border-bottom-style: var(--tw-border-style);
301
+
border-bottom-width: 1px;
302
+
}
303
+
.border-l-4 {
304
+
border-left-style: var(--tw-border-style);
305
+
border-left-width: 4px;
306
+
}
307
+
.border-\[\#1DB954\] {
308
+
border-color: #1DB954;
309
+
}
310
+
.border-gray-200 {
311
+
border-color: var(--color-gray-200);
312
+
}
313
+
.border-gray-300 {
314
+
border-color: var(--color-gray-300);
315
+
}
316
+
.bg-\[\#1DB954\] {
317
+
background-color: #1DB954;
318
+
}
319
+
.bg-\[\#d51007\] {
320
+
background-color: #d51007;
321
+
}
322
+
.bg-\[\#dc3545\] {
323
+
background-color: #dc3545;
324
+
}
325
+
.bg-gray-100 {
326
+
background-color: var(--color-gray-100);
327
+
}
328
+
.p-2 {
329
+
padding: calc(var(--spacing) * 2);
330
+
}
331
+
.p-4 {
332
+
padding: calc(var(--spacing) * 4);
333
+
}
334
+
.p-5 {
335
+
padding: calc(var(--spacing) * 5);
336
+
}
337
+
.px-3 {
338
+
padding-inline: calc(var(--spacing) * 3);
339
+
}
340
+
.px-4 {
341
+
padding-inline: calc(var(--spacing) * 4);
342
+
}
343
+
.py-1\.5 {
344
+
padding-block: calc(var(--spacing) * 1.5);
345
+
}
346
+
.py-2 {
347
+
padding-block: calc(var(--spacing) * 2);
348
+
}
349
+
.py-2\.5 {
350
+
padding-block: calc(var(--spacing) * 2.5);
351
+
}
352
+
.pl-5 {
353
+
padding-left: calc(var(--spacing) * 5);
354
+
}
355
+
.text-left {
356
+
text-align: left;
357
+
}
358
+
.font-mono {
359
+
font-family: var(--font-mono);
360
+
}
361
+
.font-sans {
362
+
font-family: var(--font-sans);
363
+
}
364
+
.text-lg {
365
+
font-size: var(--text-lg);
366
+
line-height: var(--tw-leading, var(--text-lg--line-height));
367
+
}
368
+
.text-xl {
369
+
font-size: var(--text-xl);
370
+
line-height: var(--tw-leading, var(--text-xl--line-height));
371
+
}
372
+
.leading-relaxed {
373
+
--tw-leading: var(--leading-relaxed);
374
+
line-height: var(--leading-relaxed);
375
+
}
376
+
.font-bold {
377
+
--tw-font-weight: var(--font-weight-bold);
378
+
font-weight: var(--font-weight-bold);
379
+
}
380
+
.font-semibold {
381
+
--tw-font-weight: var(--font-weight-semibold);
382
+
font-weight: var(--font-weight-semibold);
383
+
}
384
+
.text-\[\#1DB954\] {
385
+
color: #1DB954;
386
+
}
387
+
.text-gray-600 {
388
+
color: var(--color-gray-600);
389
+
}
390
+
.text-white {
391
+
color: var(--color-white);
392
+
}
393
+
.lowercase {
394
+
text-transform: lowercase;
395
+
}
396
+
.italic {
397
+
font-style: italic;
398
+
}
399
+
.no-underline {
400
+
text-decoration-line: none;
401
+
}
402
+
.filter {
403
+
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
404
+
}
405
+
.hover\:opacity-90 {
406
+
&:hover {
407
+
@media (hover: hover) {
408
+
opacity: 90%;
409
+
}
410
+
}
411
+
}
412
+
}
413
+
@property --tw-rotate-x {
414
+
syntax: "*";
415
+
inherits: false;
416
+
}
417
+
@property --tw-rotate-y {
418
+
syntax: "*";
419
+
inherits: false;
420
+
}
421
+
@property --tw-rotate-z {
422
+
syntax: "*";
423
+
inherits: false;
424
+
}
425
+
@property --tw-skew-x {
426
+
syntax: "*";
427
+
inherits: false;
428
+
}
429
+
@property --tw-skew-y {
430
+
syntax: "*";
431
+
inherits: false;
432
+
}
433
+
@property --tw-space-y-reverse {
434
+
syntax: "*";
435
+
inherits: false;
436
+
initial-value: 0;
437
+
}
438
+
@property --tw-border-style {
439
+
syntax: "*";
440
+
inherits: false;
441
+
initial-value: solid;
442
+
}
443
+
@property --tw-leading {
444
+
syntax: "*";
445
+
inherits: false;
446
+
}
447
+
@property --tw-font-weight {
448
+
syntax: "*";
449
+
inherits: false;
450
+
}
451
+
@property --tw-blur {
452
+
syntax: "*";
453
+
inherits: false;
454
+
}
455
+
@property --tw-brightness {
456
+
syntax: "*";
457
+
inherits: false;
458
+
}
459
+
@property --tw-contrast {
460
+
syntax: "*";
461
+
inherits: false;
462
+
}
463
+
@property --tw-grayscale {
464
+
syntax: "*";
465
+
inherits: false;
466
+
}
467
+
@property --tw-hue-rotate {
468
+
syntax: "*";
469
+
inherits: false;
470
+
}
471
+
@property --tw-invert {
472
+
syntax: "*";
473
+
inherits: false;
474
+
}
475
+
@property --tw-opacity {
476
+
syntax: "*";
477
+
inherits: false;
478
+
}
479
+
@property --tw-saturate {
480
+
syntax: "*";
481
+
inherits: false;
482
+
}
483
+
@property --tw-sepia {
484
+
syntax: "*";
485
+
inherits: false;
486
+
}
487
+
@property --tw-drop-shadow {
488
+
syntax: "*";
489
+
inherits: false;
490
+
}
491
+
@property --tw-drop-shadow-color {
492
+
syntax: "*";
493
+
inherits: false;
494
+
}
495
+
@property --tw-drop-shadow-alpha {
496
+
syntax: "<percentage>";
497
+
inherits: false;
498
+
initial-value: 100%;
499
+
}
500
+
@property --tw-drop-shadow-size {
501
+
syntax: "*";
502
+
inherits: false;
503
+
}
504
+
@layer properties {
505
+
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
506
+
*, ::before, ::after, ::backdrop {
507
+
--tw-rotate-x: initial;
508
+
--tw-rotate-y: initial;
509
+
--tw-rotate-z: initial;
510
+
--tw-skew-x: initial;
511
+
--tw-skew-y: initial;
512
+
--tw-space-y-reverse: 0;
513
+
--tw-border-style: solid;
514
+
--tw-leading: initial;
515
+
--tw-font-weight: initial;
516
+
--tw-blur: initial;
517
+
--tw-brightness: initial;
518
+
--tw-contrast: initial;
519
+
--tw-grayscale: initial;
520
+
--tw-hue-rotate: initial;
521
+
--tw-invert: initial;
522
+
--tw-opacity: initial;
523
+
--tw-saturate: initial;
524
+
--tw-sepia: initial;
525
+
--tw-drop-shadow: initial;
526
+
--tw-drop-shadow-color: initial;
527
+
--tw-drop-shadow-alpha: 100%;
528
+
--tw-drop-shadow-size: initial;
529
+
}
530
+
}
531
+
}
+92
pages/templates/apiKeys.gohtml
+92
pages/templates/apiKeys.gohtml
···
1
+
2
+
{{ define "content" }}
3
+
4
+
{{ template "components/navBar" .NavBar }}
5
+
6
+
7
+
<h1 class="text-[#1DB954]">API Key Management</h1>
8
+
9
+
<div class="border border-gray-300 rounded-lg p-5 mb-5">
10
+
<h2 class="text-[#1DB954] text-xl font-semibold mb-2">Create New API Key</h2>
11
+
<p class="mb-3">API keys allow programmatic access to your Piper account data.</p>
12
+
<form method="POST" action="/api-keys">
13
+
<div class="mb-4">
14
+
<label class="block" for="name">Key Name (for your reference):</label>
15
+
<input class="mt-1 w-full p-2 border border-gray-300 rounded" type="text" id="name" name="name" placeholder="My Application">
16
+
</div>
17
+
<button type="submit" class="bg-[#1DB954] text-white px-4 py-2 rounded cursor-pointer hover:opacity-90">Generate New API Key</button>
18
+
</form>
19
+
</div>
20
+
21
+
{{if .NewKeyID}} <!-- Changed from .NewKey to .NewKeyID for clarity -->
22
+
<div class="bg-gray-100 border-l-4 border-[#1DB954] p-4 mb-5">
23
+
<h3 class="text-[#1DB954] text-lg font-semibold mb-1">Your new API key (ID: {{.NewKeyID}}) has been created</h3>
24
+
<!-- The message below is misleading if only the ID is shown.
25
+
Consider changing this text or modifying the flow to show the actual key once for HTML. -->
26
+
<p><strong>Important:</strong> If this is an ID, ensure you have copied the actual key if it was displayed previously. For keys generated via the API, the key is returned in the API response.</p>
27
+
</div>
28
+
{{end}}
29
+
30
+
<div class="border border-gray-300 rounded-lg p-5 mb-5">
31
+
<h2 class="text-[#1DB954] text-xl font-semibold mb-2">Your API Keys</h2>
32
+
{{if .Keys}}
33
+
<table class="w-full border-collapse">
34
+
<thead>
35
+
<tr class="text-left border-b border-gray-300">
36
+
<th class="p-2">Name</th>
37
+
<th class="p-2">Prefix</th>
38
+
<th class="p-2">Created</th>
39
+
<th class="p-2">Expires</th>
40
+
<th class="p-2">Actions</th>
41
+
</tr>
42
+
</thead>
43
+
<tbody>
44
+
{{range .Keys}}
45
+
<tr class="border-b border-gray-200">
46
+
<td class="p-2">{{.Name}}</td>
47
+
<td class="p-2">{{.KeyPrefix}}</td> <!-- Added KeyPrefix for better identification -->
48
+
<td class="p-2">{{formatTime .CreatedAt}}</td>
49
+
<td class="p-2">{{formatTime .ExpiresAt}}</td>
50
+
<td class="p-2">
51
+
<button class="bg-[#dc3545] text-white px-3 py-1.5 rounded cursor-pointer hover:opacity-90" onclick="deleteKey('{{.ID}}')">Delete</button>
52
+
</td>
53
+
</tr>
54
+
{{end}}
55
+
</tbody>
56
+
</table>
57
+
{{else}}
58
+
<p>You don't have any API keys yet.</p>
59
+
{{end}}
60
+
</div>
61
+
62
+
<div class="border border-gray-300 rounded-lg p-5 mb-5">
63
+
<h2 class="text-[#1DB954] text-xl font-semibold mb-2">API Usage</h2>
64
+
<p class="mb-2">To use your API key, include it in the Authorization header of your HTTP requests:</p>
65
+
<pre class="font-mono p-2 bg-gray-100 border border-gray-300 rounded">Authorization: Bearer YOUR_API_KEY</pre>
66
+
<p class="mt-3 mb-2">Or include it as a query parameter (less secure for the key itself):</p>
67
+
<pre class="font-mono p-2 bg-gray-100 border border-gray-300 rounded">https://your-piper-instance.com/endpoint?api_key=YOUR_API_KEY</pre>
68
+
</div>
69
+
70
+
<script>
71
+
function deleteKey(keyId) {
72
+
if (confirm('Are you sure you want to delete this API key? This action cannot be undone.')) {
73
+
fetch('/api-keys?key_id=' + keyId, { // This endpoint is handled by HandleAPIKeyManagement
74
+
method: 'DELETE',
75
+
})
76
+
.then(response => response.json())
77
+
.then(data => {
78
+
if (data.success) {
79
+
window.location.reload();
80
+
} else {
81
+
alert('Failed to delete API key: ' + (data.error || 'Unknown error'));
82
+
}
83
+
})
84
+
.catch(error => {
85
+
console.error('Error:', error);
86
+
alert('Failed to delete API key due to a network or processing error.');
87
+
});
88
+
}
89
+
}
90
+
</script>
91
+
92
+
{{ end }}
+48
pages/templates/home.gohtml
+48
pages/templates/home.gohtml
···
1
+
2
+
{{ define "content" }}
3
+
4
+
<h1 class="text-[#1DB954]">Piper - Multi-User Spotify & Last.fm Tracker via ATProto</h1>
5
+
{{ template "components/navBar" .NavBar }}
6
+
7
+
8
+
<div class="border border-gray-300 rounded-lg p-5 mb-5">
9
+
<h2 class="text-xl font-semibold mb-2">Welcome to Piper</h2>
10
+
<p class="mb-3">Piper is a multi-user application that records what you're listening to on Spotify and Last.fm, saving your listening history.</p>
11
+
12
+
{{if .NavBar.IsLoggedIn}}
13
+
<p class="mb-2">You're logged in!</p>
14
+
<ul class="list-disc pl-5 mb-3">
15
+
<li><a class="text-[#1DB954] font-bold" href="/login/spotify">Connect your Spotify account</a> to start tracking.</li>
16
+
<li><a class="text-[#1DB954] font-bold" href="/link-lastfm">Link your Last.fm account</a> to track scrobbles.</li>
17
+
</ul>
18
+
<p class="mb-2">Once connected, you can check out your:</p>
19
+
<ul class="list-disc pl-5 mb-3">
20
+
<li><a class="text-[#1DB954] font-bold" href="/current-track">Spotify current track</a> or <a class="text-[#1DB954] font-bold" href="/history">listening history</a>.</li>
21
+
{{ if .NavBar.LastFMUsername }}
22
+
<li><a class="text-[#1DB954] font-bold" href="/lastfm/recent">Last.fm recent tracks</a>.</li>
23
+
{{ end }}
24
+
25
+
</ul>
26
+
<p class="mb-3">You can also manage your <a class="text-[#1DB954] font-bold" href="/api-keys">API keys</a> for programmatic access.</p>
27
+
28
+
{{ if .NavBar.LastFMUsername }}
29
+
<p class='italic text-gray-600'>Last.fm Username: {{ .NavBar.LastFMUsername }}</p>
30
+
{{else }}
31
+
<p class='italic text-gray-600'>Last.fm account not linked.</p>
32
+
{{end}}
33
+
34
+
35
+
{{ else }}
36
+
37
+
<p class="mb-3">Login with ATProto to get started!</p>
38
+
<form class="space-y-2" action="/login/atproto">
39
+
<label class="block" for="handle">handle:</label>
40
+
<input class="block w-[95%] p-2 border border-gray-300 rounded" type="text" id="handle" name="handle" >
41
+
<input class="bg-[#1DB954] text-white px-4 py-2.5 rounded cursor-pointer hover:opacity-90" type="submit" value="submit">
42
+
</form>
43
+
44
+
45
+
{{ end }}
46
+
</div> <!-- Close card div -->
47
+
48
+
{{ end }}
+14
pages/templates/lastFMForm.gohtml
+14
pages/templates/lastFMForm.gohtml
···
1
+
{{ define "content" }}
2
+
{{ template "components/navBar" .NavBar }}
3
+
4
+
<div class="max-w-[600px] mx-auto my-5 p-5 border border-gray-300 rounded-lg">
5
+
<h2 class="text-xl font-semibold mb-2">Link Your Last.fm Account</h2>
6
+
<p class="mb-3">Enter your Last.fm username to start tracking your scrobbles.</p>
7
+
<form class="space-y-2" method="post" action="/link-lastfm">
8
+
<label class="block" for="lastfm_username">Last.fm Username:</label>
9
+
<input class="block w-[95%] p-2 border border-gray-300 rounded" type="text" id="lastfm_username" name="lastfm_username" value="{{.CurrentUsername}}" required>
10
+
<input class="bg-[#d51007] text-white px-4 py-2.5 rounded cursor-pointer hover:opacity-90" type="submit" value="Save Username">
11
+
</form>
12
+
</div>
13
+
14
+
{{ end }}
+13
pages/templates/layouts/base.gohtml
+13
pages/templates/layouts/base.gohtml
···
1
+
{{ define "layouts/base" }}
2
+
3
+
<html lang="en">
4
+
<head>
5
+
<title>Piper - Spotify & Last.fm Tracker</title>
6
+
<link rel="stylesheet" href="/static/main.css">
7
+
</head>
8
+
<body class="font-sans max-w-[800px] mx-auto p-5 leading-relaxed">
9
+
{{ block "content" . }}{{ end }}
10
+
11
+
</body>
12
+
</html>
13
+
{{ end }}
+142
-302
service/apikey/apikey.go
+142
-302
service/apikey/apikey.go
···
3
3
import (
4
4
"encoding/json"
5
5
"fmt"
6
-
"html/template"
7
6
"log"
8
7
"net/http"
9
8
"time"
10
9
11
10
"github.com/teal-fm/piper/db"
12
11
db_apikey "github.com/teal-fm/piper/db/apikey" // Assuming this is the package for ApiKey struct
12
+
"github.com/teal-fm/piper/pages"
13
13
"github.com/teal-fm/piper/session"
14
14
)
15
15
···
41
41
jsonResponse(w, statusCode, map[string]string{"error": message})
42
42
}
43
43
44
-
func (s *Service) HandleAPIKeyManagement(w http.ResponseWriter, r *http.Request) {
45
-
userID, ok := session.GetUserID(r.Context())
46
-
if !ok {
47
-
// If this is an API request context, it might have already been handled by WithAPIAuth,
48
-
// but an extra check or appropriate error for the context is good.
49
-
if session.IsAPIRequest(r.Context()) {
50
-
jsonError(w, "Unauthorized", http.StatusUnauthorized)
51
-
} else {
52
-
http.Error(w, "Unauthorized", http.StatusUnauthorized)
44
+
func (s *Service) HandleAPIKeyManagement(database *db.DB, pg *pages.Pages) http.HandlerFunc {
45
+
return func(w http.ResponseWriter, r *http.Request) {
46
+
47
+
userID, ok := session.GetUserID(r.Context())
48
+
if !ok {
49
+
// If this is an API request context, it might have already been handled by WithAPIAuth,
50
+
// but an extra check or appropriate error for the context is good.
51
+
if session.IsAPIRequest(r.Context()) {
52
+
jsonError(w, "Unauthorized", http.StatusUnauthorized)
53
+
} else {
54
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
55
+
}
56
+
return
53
57
}
54
-
return
55
-
}
58
+
59
+
lastfmUsername := ""
60
+
user, err := database.GetUserByID(userID)
61
+
if err == nil && user != nil && user.LastFMUsername != nil {
62
+
lastfmUsername = *user.LastFMUsername
63
+
} else if err != nil {
64
+
log.Printf("Error fetching user %d details for home page: %v", userID, err)
65
+
}
66
+
isAPI := session.IsAPIRequest(r.Context())
67
+
68
+
if isAPI { // JSON API Handling
69
+
switch r.Method {
70
+
case http.MethodGet:
71
+
keys, err := s.sessions.GetAPIKeyManager().GetUserApiKeys(userID)
72
+
if err != nil {
73
+
jsonError(w, fmt.Sprintf("Error fetching API keys: %v", err), http.StatusInternalServerError)
74
+
return
75
+
}
76
+
// Ensure keys are safe for listing (e.g., no raw key string)
77
+
// GetUserApiKeys should return a slice of db_apikey.ApiKey or similar struct
78
+
// that includes ID, Name, KeyPrefix, CreatedAt, ExpiresAt.
79
+
jsonResponse(w, http.StatusOK, map[string]any{"api_keys": keys})
80
+
81
+
case http.MethodPost:
82
+
var reqBody struct {
83
+
Name string `json:"name"`
84
+
}
85
+
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
86
+
jsonError(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
87
+
return
88
+
}
89
+
keyName := reqBody.Name
90
+
if keyName == "" {
91
+
keyName = fmt.Sprintf("API Key (via API) - %s", time.Now().UTC().Format(time.RFC3339))
92
+
}
93
+
validityDays := 30 // Default, could be made configurable via request body
94
+
95
+
// IMPORTANT: Assumes CreateAPIKeyAndReturnRawKey method exists on SessionManager
96
+
// and returns the database object and the raw key string.
97
+
// Signature: (apiKey *db_apikey.ApiKey, rawKeyString string, err error)
98
+
apiKeyObj, err := s.sessions.CreateAPIKey(userID, keyName, validityDays)
99
+
if err != nil {
100
+
jsonError(w, fmt.Sprintf("Error creating API key: %v", err), http.StatusInternalServerError)
101
+
return
102
+
}
103
+
104
+
jsonResponse(w, http.StatusCreated, map[string]any{
105
+
"id": apiKeyObj.ID,
106
+
"name": apiKeyObj.Name,
107
+
"created_at": apiKeyObj.CreatedAt,
108
+
"expires_at": apiKeyObj.ExpiresAt,
109
+
})
110
+
111
+
case http.MethodDelete:
112
+
keyID := r.URL.Query().Get("key_id")
113
+
if keyID == "" {
114
+
jsonError(w, "Query parameter 'key_id' is required", http.StatusBadRequest)
115
+
return
116
+
}
56
117
57
-
isAPI := session.IsAPIRequest(r.Context())
118
+
key, exists := s.sessions.GetAPIKeyManager().GetApiKey(keyID)
119
+
if !exists || key.UserID != userID {
120
+
jsonError(w, "API key not found or not owned by user", http.StatusNotFound)
121
+
return
122
+
}
58
123
59
-
if isAPI { // JSON API Handling
60
-
switch r.Method {
61
-
case http.MethodGet:
62
-
keys, err := s.sessions.GetAPIKeyManager().GetUserApiKeys(userID)
63
-
if err != nil {
64
-
jsonError(w, fmt.Sprintf("Error fetching API keys: %v", err), http.StatusInternalServerError)
65
-
return
66
-
}
67
-
// Ensure keys are safe for listing (e.g., no raw key string)
68
-
// GetUserApiKeys should return a slice of db_apikey.ApiKey or similar struct
69
-
// that includes ID, Name, KeyPrefix, CreatedAt, ExpiresAt.
70
-
jsonResponse(w, http.StatusOK, map[string]any{"api_keys": keys})
124
+
if err := s.sessions.GetAPIKeyManager().DeleteApiKey(keyID); err != nil {
125
+
jsonError(w, fmt.Sprintf("Error deleting API key: %v", err), http.StatusInternalServerError)
126
+
return
127
+
}
128
+
jsonResponse(w, http.StatusOK, map[string]string{"message": "API key deleted successfully"})
71
129
72
-
case http.MethodPost:
73
-
var reqBody struct {
74
-
Name string `json:"name"`
130
+
default:
131
+
jsonError(w, "Method not allowed", http.StatusMethodNotAllowed)
75
132
}
76
-
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
77
-
jsonError(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
133
+
return // End of JSON API handling
134
+
}
135
+
136
+
// HTML UI Handling (largely existing logic)
137
+
if r.Method == http.MethodPost { // Create key from HTML form
138
+
if err := r.ParseForm(); err != nil {
139
+
http.Error(w, "Invalid form data", http.StatusBadRequest)
78
140
return
79
141
}
80
-
keyName := reqBody.Name
142
+
143
+
keyName := r.FormValue("name")
81
144
if keyName == "" {
82
-
keyName = fmt.Sprintf("API Key (via API) - %s", time.Now().Format(time.RFC3339))
145
+
keyName = fmt.Sprintf("API Key - %s", time.Now().UTC().Format(time.RFC3339))
83
146
}
84
-
validityDays := 30 // Default, could be made configurable via request body
147
+
validityDays := 1024
85
148
86
-
// IMPORTANT: Assumes CreateAPIKeyAndReturnRawKey method exists on SessionManager
87
-
// and returns the database object and the raw key string.
88
-
// Signature: (apiKey *db_apikey.ApiKey, rawKeyString string, err error)
89
-
apiKeyObj, err := s.sessions.CreateAPIKey(userID, keyName, validityDays)
149
+
// Uses the existing CreateAPIKey, which likely doesn't return the raw key.
150
+
// The HTML flow currently redirects and shows the key ID.
151
+
// The template message about "only time you'll see this key" is misleading if it shows ID.
152
+
// This might require a separate enhancement if the HTML view should show the raw key.
153
+
apiKey, err := s.sessions.CreateAPIKey(userID, keyName, validityDays)
90
154
if err != nil {
91
-
jsonError(w, fmt.Sprintf("Error creating API key: %v", err), http.StatusInternalServerError)
155
+
http.Error(w, fmt.Sprintf("Error creating API key: %v", err), http.StatusInternalServerError)
92
156
return
93
157
}
94
-
95
-
jsonResponse(w, http.StatusCreated, map[string]any{
96
-
"id": apiKeyObj.ID,
97
-
"name": apiKeyObj.Name,
98
-
"created_at": apiKeyObj.CreatedAt,
99
-
"expires_at": apiKeyObj.ExpiresAt,
100
-
})
158
+
// Redirects, passing the ID of the created key.
159
+
// The template shows this ID in the ".NewKey" section.
160
+
http.Redirect(w, r, "/api-keys?created="+apiKey.ID, http.StatusSeeOther)
161
+
return
162
+
}
101
163
102
-
case http.MethodDelete:
164
+
if r.Method == http.MethodDelete { // Delete key via AJAX from HTML page
103
165
keyID := r.URL.Query().Get("key_id")
104
166
if keyID == "" {
105
-
jsonError(w, "Query parameter 'key_id' is required", http.StatusBadRequest)
167
+
// For AJAX, a JSON error response is more appropriate than http.Error
168
+
jsonError(w, "Key ID is required", http.StatusBadRequest)
106
169
return
107
170
}
108
171
109
172
key, exists := s.sessions.GetAPIKeyManager().GetApiKey(keyID)
110
173
if !exists || key.UserID != userID {
111
-
jsonError(w, "API key not found or not owned by user", http.StatusNotFound)
174
+
jsonError(w, "Invalid API key or not owned by user", http.StatusBadRequest) // StatusNotFound or StatusForbidden
112
175
return
113
176
}
114
177
···
116
179
jsonError(w, fmt.Sprintf("Error deleting API key: %v", err), http.StatusInternalServerError)
117
180
return
118
181
}
119
-
jsonResponse(w, http.StatusOK, map[string]string{"message": "API key deleted successfully"})
120
-
121
-
default:
122
-
jsonError(w, "Method not allowed", http.StatusMethodNotAllowed)
123
-
}
124
-
return // End of JSON API handling
125
-
}
126
-
127
-
// HTML UI Handling (largely existing logic)
128
-
if r.Method == http.MethodPost { // Create key from HTML form
129
-
if err := r.ParseForm(); err != nil {
130
-
http.Error(w, "Invalid form data", http.StatusBadRequest)
182
+
// AJAX client expects JSON
183
+
jsonResponse(w, http.StatusOK, map[string]any{"success": true})
131
184
return
132
185
}
133
186
134
-
keyName := r.FormValue("name")
135
-
if keyName == "" {
136
-
keyName = fmt.Sprintf("API Key - %s", time.Now().Format(time.RFC3339))
137
-
}
138
-
validityDays := 1024
139
-
140
-
// Uses the existing CreateAPIKey, which likely doesn't return the raw key.
141
-
// The HTML flow currently redirects and shows the key ID.
142
-
// The template message about "only time you'll see this key" is misleading if it shows ID.
143
-
// This might require a separate enhancement if the HTML view should show the raw key.
144
-
apiKey, err := s.sessions.CreateAPIKey(userID, keyName, validityDays)
187
+
// GET request: Display HTML page for API Key Management
188
+
keys, err := s.sessions.GetAPIKeyManager().GetUserApiKeys(userID)
145
189
if err != nil {
146
-
http.Error(w, fmt.Sprintf("Error creating API key: %v", err), http.StatusInternalServerError)
190
+
http.Error(w, fmt.Sprintf("Error fetching API keys: %v", err), http.StatusInternalServerError)
147
191
return
148
192
}
149
-
// Redirects, passing the ID of the created key.
150
-
// The template shows this ID in the ".NewKey" section.
151
-
http.Redirect(w, r, "/api-keys?created="+apiKey.ID, http.StatusSeeOther)
152
-
return
153
-
}
154
193
155
-
if r.Method == http.MethodDelete { // Delete key via AJAX from HTML page
156
-
keyID := r.URL.Query().Get("key_id")
157
-
if keyID == "" {
158
-
// For AJAX, a JSON error response is more appropriate than http.Error
159
-
jsonError(w, "Key ID is required", http.StatusBadRequest)
160
-
return
161
-
}
194
+
// newlyCreatedKey will be the ID from the redirect after form POST
195
+
newlyCreatedKeyID := r.URL.Query().Get("created")
196
+
var newKeyValueToShow string
162
197
163
-
key, exists := s.sessions.GetAPIKeyManager().GetApiKey(keyID)
164
-
if !exists || key.UserID != userID {
165
-
jsonError(w, "Invalid API key or not owned by user", http.StatusBadRequest) // StatusNotFound or StatusForbidden
166
-
return
198
+
if newlyCreatedKeyID != "" {
199
+
// For HTML, we only have the ID. The template message should be adjusted
200
+
// if it implies the raw key is shown.
201
+
// If you enhance CreateAPIKey for HTML to also pass the raw key (e.g. via flash message),
202
+
// this logic would change. For now, it's the ID.
203
+
newKeyValueToShow = newlyCreatedKeyID
167
204
}
168
205
169
-
if err := s.sessions.GetAPIKeyManager().DeleteApiKey(keyID); err != nil {
170
-
jsonError(w, fmt.Sprintf("Error deleting API key: %v", err), http.StatusInternalServerError)
171
-
return
206
+
data := struct {
207
+
Keys []*db_apikey.ApiKey // Assuming GetUserApiKeys returns this type
208
+
NewKeyID string // Changed from NewKey for clarity as it's an ID
209
+
NavBar pages.NavBar
210
+
}{
211
+
Keys: keys,
212
+
NewKeyID: newKeyValueToShow,
213
+
NavBar: pages.NavBar{
214
+
IsLoggedIn: ok,
215
+
//Just leaving empty so we don't have to pull in the db here, may change
216
+
LastFMUsername: lastfmUsername,
217
+
},
172
218
}
173
-
// AJAX client expects JSON
174
-
jsonResponse(w, http.StatusOK, map[string]any{"success": true})
175
-
return
176
-
}
177
219
178
-
// GET request: Display HTML page for API Key Management
179
-
keys, err := s.sessions.GetAPIKeyManager().GetUserApiKeys(userID)
180
-
if err != nil {
181
-
http.Error(w, fmt.Sprintf("Error fetching API keys: %v", err), http.StatusInternalServerError)
182
-
return
183
-
}
184
-
185
-
// newlyCreatedKey will be the ID from the redirect after form POST
186
-
newlyCreatedKeyID := r.URL.Query().Get("created")
187
-
var newKeyValueToShow string
188
-
189
-
if newlyCreatedKeyID != "" {
190
-
// For HTML, we only have the ID. The template message should be adjusted
191
-
// if it implies the raw key is shown.
192
-
// If you enhance CreateAPIKey for HTML to also pass the raw key (e.g. via flash message),
193
-
// this logic would change. For now, it's the ID.
194
-
newKeyValueToShow = newlyCreatedKeyID
195
-
}
196
-
197
-
tmpl := `
198
-
<!DOCTYPE html>
199
-
<html>
200
-
<head>
201
-
<title>API Key Management - Piper</title>
202
-
<style>
203
-
body {
204
-
font-family: Arial, sans-serif;
205
-
max-width: 800px;
206
-
margin: 0 auto;
207
-
padding: 20px;
208
-
line-height: 1.6;
209
-
}
210
-
h1, h2 {
211
-
color: #1DB954; /* Spotify green */
212
-
}
213
-
.nav {
214
-
display: flex;
215
-
margin-bottom: 20px;
216
-
}
217
-
.nav a {
218
-
margin-right: 15px;
219
-
text-decoration: none;
220
-
color: #1DB954;
221
-
font-weight: bold;
222
-
}
223
-
.card {
224
-
border: 1px solid #ddd;
225
-
border-radius: 8px;
226
-
padding: 20px;
227
-
margin-bottom: 20px;
228
-
}
229
-
table {
230
-
width: 100%;
231
-
border-collapse: collapse;
232
-
}
233
-
table th, table td {
234
-
padding: 8px;
235
-
text-align: left;
236
-
border-bottom: 1px solid #ddd;
237
-
}
238
-
.key-value {
239
-
font-family: monospace;
240
-
padding: 10px;
241
-
background-color: #f5f5f5;
242
-
border: 1px solid #ddd;
243
-
border-radius: 4px;
244
-
word-break: break-all;
245
-
}
246
-
.new-key-alert {
247
-
background-color: #f8f9fa;
248
-
border-left: 4px solid #1DB954;
249
-
padding: 15px;
250
-
margin-bottom: 20px;
251
-
}
252
-
.btn {
253
-
padding: 8px 16px;
254
-
background-color: #1DB954;
255
-
color: white;
256
-
border: none;
257
-
border-radius: 4px;
258
-
cursor: pointer;
259
-
}
260
-
.btn-danger {
261
-
background-color: #dc3545;
262
-
}
263
-
</style>
264
-
</head>
265
-
<body>
266
-
<div class="nav">
267
-
<a href="/">Home</a>
268
-
<a href="/current-track">Current Track</a>
269
-
<a href="/history">Track History</a>
270
-
<a href="/api-keys" class="active">API Keys</a>
271
-
<a href="/logout">Logout</a>
272
-
</div>
273
-
274
-
<h1>API Key Management</h1>
275
-
276
-
<div class="card">
277
-
<h2>Create New API Key</h2>
278
-
<p>API keys allow programmatic access to your Piper account data.</p>
279
-
<form method="POST" action="/api-keys">
280
-
<div style="margin-bottom: 15px;">
281
-
<label for="name">Key Name (for your reference):</label>
282
-
<input type="text" id="name" name="name" placeholder="My Application" style="width: 100%; padding: 8px; margin-top: 5px;">
283
-
</div>
284
-
<button type="submit" class="btn">Generate New API Key</button>
285
-
</form>
286
-
</div>
287
-
288
-
{{if .NewKeyID}} <!-- Changed from .NewKey to .NewKeyID for clarity -->
289
-
<div class="new-key-alert">
290
-
<h3>Your new API key (ID: {{.NewKeyID}}) has been created</h3>
291
-
<!-- The message below is misleading if only the ID is shown.
292
-
Consider changing this text or modifying the flow to show the actual key once for HTML. -->
293
-
<p><strong>Important:</strong> If this is an ID, ensure you have copied the actual key if it was displayed previously. For keys generated via the API, the key is returned in the API response.</p>
294
-
</div>
295
-
{{end}}
296
-
297
-
<div class="card">
298
-
<h2>Your API Keys</h2>
299
-
{{if .Keys}}
300
-
<table>
301
-
<thead>
302
-
<tr>
303
-
<th>Name</th>
304
-
<th>Prefix</th>
305
-
<th>Created</th>
306
-
<th>Expires</th>
307
-
<th>Actions</th>
308
-
</tr>
309
-
</thead>
310
-
<tbody>
311
-
{{range .Keys}}
312
-
<tr>
313
-
<td>{{.Name}}</td>
314
-
<td>{{.KeyPrefix}}</td> <!-- Added KeyPrefix for better identification -->
315
-
<td>{{formatTime .CreatedAt}}</td>
316
-
<td>{{formatTime .ExpiresAt}}</td>
317
-
<td>
318
-
<button class="btn btn-danger" onclick="deleteKey('{{.ID}}')">Delete</button>
319
-
</td>
320
-
</tr>
321
-
{{end}}
322
-
</tbody>
323
-
</table>
324
-
{{else}}
325
-
<p>You don't have any API keys yet.</p>
326
-
{{end}}
327
-
</div>
328
-
329
-
<div class="card">
330
-
<h2>API Usage</h2>
331
-
<p>To use your API key, include it in the Authorization header of your HTTP requests:</p>
332
-
<pre>Authorization: Bearer YOUR_API_KEY</pre>
333
-
<p>Or include it as a query parameter (less secure for the key itself):</p>
334
-
<pre>https://your-piper-instance.com/endpoint?api_key=YOUR_API_KEY</pre>
335
-
</div>
336
-
337
-
<script>
338
-
function deleteKey(keyId) {
339
-
if (confirm('Are you sure you want to delete this API key? This action cannot be undone.')) {
340
-
fetch('/api-keys?key_id=' + keyId, { // This endpoint is handled by HandleAPIKeyManagement
341
-
method: 'DELETE',
342
-
})
343
-
.then(response => response.json())
344
-
.then(data => {
345
-
if (data.success) {
346
-
window.location.reload();
347
-
} else {
348
-
alert('Failed to delete API key: ' + (data.error || 'Unknown error'));
349
-
}
350
-
})
351
-
.catch(error => {
352
-
console.error('Error:', error);
353
-
alert('Failed to delete API key due to a network or processing error.');
354
-
});
355
-
}
356
-
}
357
-
</script>
358
-
</body>
359
-
</html>
360
-
`
361
-
funcMap := template.FuncMap{
362
-
"formatTime": func(t time.Time) string {
363
-
if t.IsZero() {
364
-
return "N/A"
365
-
}
366
-
return t.Format("Jan 02, 2006 15:04")
367
-
},
220
+
w.Header().Set("Content-Type", "text/html")
221
+
err = pg.Execute("apiKeys", w, data)
222
+
if err != nil {
223
+
log.Printf("Error executing template: %v", err)
224
+
}
368
225
}
369
-
370
-
t, err := template.New("apikeys").Funcs(funcMap).Parse(tmpl)
371
-
if err != nil {
372
-
http.Error(w, fmt.Sprintf("Error parsing template: %v", err), http.StatusInternalServerError)
373
-
return
374
-
}
375
-
376
-
data := struct {
377
-
Keys []*db_apikey.ApiKey // Assuming GetUserApiKeys returns this type
378
-
NewKeyID string // Changed from NewKey for clarity as it's an ID
379
-
}{
380
-
Keys: keys,
381
-
NewKeyID: newKeyValueToShow,
382
-
}
383
-
384
-
w.Header().Set("Content-Type", "text/html")
385
-
t.Execute(w, data)
386
226
}
+121
service/atproto/submission.go
+121
service/atproto/submission.go
···
1
+
package atproto
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"log"
7
+
"time"
8
+
9
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
10
+
lexutil "github.com/bluesky-social/indigo/lex/util"
11
+
"github.com/spf13/viper"
12
+
"github.com/teal-fm/piper/api/teal"
13
+
"github.com/teal-fm/piper/models"
14
+
atprotoauth "github.com/teal-fm/piper/oauth/atproto"
15
+
)
16
+
17
+
// SubmitPlayToPDS submits a track play to the ATProto PDS as a feed.play record
18
+
func SubmitPlayToPDS(ctx context.Context, did string, mostRecentAtProtoSessionID string, track *models.Track, atprotoService *atprotoauth.ATprotoAuthService) error {
19
+
if did == "" {
20
+
return fmt.Errorf("DID cannot be empty")
21
+
}
22
+
23
+
// Get ATProto client
24
+
client, err := atprotoService.GetATProtoClient(did, mostRecentAtProtoSessionID, ctx)
25
+
if err != nil || client == nil {
26
+
return fmt.Errorf("failed to get ATProto client: %w", err)
27
+
}
28
+
29
+
// Convert track to feed.play record
30
+
playRecord, err := TrackToPlayRecord(track)
31
+
if err != nil {
32
+
return fmt.Errorf("failed to convert track to play record: %w", err)
33
+
}
34
+
35
+
// Create the record
36
+
input := comatproto.RepoCreateRecord_Input{
37
+
Collection: "fm.teal.alpha.feed.play",
38
+
Repo: client.AccountDID.String(),
39
+
Record: &lexutil.LexiconTypeDecoder{Val: playRecord},
40
+
}
41
+
42
+
if _, err := comatproto.RepoCreateRecord(ctx, client, &input); err != nil {
43
+
return fmt.Errorf("failed to create play record for DID %s: %w", did, err)
44
+
}
45
+
46
+
log.Printf("Successfully submitted play to PDS for DID %s: %s - %s", did, track.Artist[0].Name, track.Name)
47
+
return nil
48
+
}
49
+
50
+
// TrackToPlayRecord converts a models.Track to teal.AlphaFeedPlay
51
+
func TrackToPlayRecord(track *models.Track) (*teal.AlphaFeedPlay, error) {
52
+
if track.Name == "" {
53
+
return nil, fmt.Errorf("track name cannot be empty")
54
+
}
55
+
56
+
// Convert artists
57
+
artists := make([]*teal.AlphaFeedDefs_Artist, 0, len(track.Artist))
58
+
for _, a := range track.Artist {
59
+
artist := &teal.AlphaFeedDefs_Artist{
60
+
ArtistName: a.Name,
61
+
ArtistMbId: a.MBID,
62
+
}
63
+
artists = append(artists, artist)
64
+
}
65
+
66
+
// Prepare optional fields
67
+
var durationPtr *int64
68
+
if track.DurationMs > 0 {
69
+
durationSeconds := track.DurationMs / 1000
70
+
durationPtr = &durationSeconds
71
+
}
72
+
73
+
var playedTimeStr *string
74
+
if !track.Timestamp.IsZero() {
75
+
timeStr := track.Timestamp.Format(time.RFC3339)
76
+
playedTimeStr = &timeStr
77
+
}
78
+
79
+
var isrcPtr *string
80
+
if track.ISRC != "" {
81
+
isrcPtr = &track.ISRC
82
+
}
83
+
84
+
var originUrlPtr *string
85
+
if track.URL != "" {
86
+
originUrlPtr = &track.URL
87
+
}
88
+
89
+
var servicePtr *string
90
+
if track.ServiceBaseUrl != "" {
91
+
servicePtr = &track.ServiceBaseUrl
92
+
}
93
+
94
+
var releaseNamePtr *string
95
+
if track.Album != "" {
96
+
releaseNamePtr = &track.Album
97
+
}
98
+
99
+
// Get submission client agent
100
+
submissionAgent := viper.GetString("app.submission_agent")
101
+
if submissionAgent == "" {
102
+
submissionAgent = "piper/v0.0.1"
103
+
}
104
+
105
+
playRecord := &teal.AlphaFeedPlay{
106
+
LexiconTypeID: "fm.teal.alpha.feed.play",
107
+
TrackName: track.Name,
108
+
Artists: artists,
109
+
Duration: durationPtr,
110
+
PlayedTime: playedTimeStr,
111
+
RecordingMbId: track.RecordingMBID,
112
+
ReleaseMbId: track.ReleaseMBID,
113
+
ReleaseName: releaseNamePtr,
114
+
Isrc: isrcPtr,
115
+
OriginUrl: originUrlPtr,
116
+
MusicServiceBaseDomain: servicePtr,
117
+
SubmissionClientAgent: &submissionAgent,
118
+
}
119
+
120
+
return playRecord, nil
121
+
}
+103
-125
service/lastfm/lastfm.go
+103
-125
service/lastfm/lastfm.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
-
"errors"
7
6
"fmt"
8
7
"io"
9
8
"log"
10
9
"net/http"
11
10
"net/url"
11
+
"os"
12
12
"strconv"
13
13
"sync"
14
14
"time"
15
15
16
-
"github.com/bluesky-social/indigo/api/atproto"
17
-
lexutil "github.com/bluesky-social/indigo/lex/util"
18
-
"github.com/bluesky-social/indigo/xrpc"
19
-
"github.com/spf13/viper"
20
-
"github.com/teal-fm/piper/api/teal"
21
16
"github.com/teal-fm/piper/db"
22
17
"github.com/teal-fm/piper/models"
23
18
atprotoauth "github.com/teal-fm/piper/oauth/atproto"
19
+
atprotoservice "github.com/teal-fm/piper/service/atproto"
24
20
"github.com/teal-fm/piper/service/musicbrainz"
25
21
"golang.org/x/time/rate"
26
22
)
···
38
34
Usernames []string
39
35
musicBrainzService *musicbrainz.MusicBrainzService
40
36
atprotoService *atprotoauth.ATprotoAuthService
37
+
playingNowService interface {
38
+
PublishPlayingNow(ctx context.Context, userID int64, track *models.Track) error
39
+
ClearPlayingNow(ctx context.Context, userID int64) error
40
+
}
41
41
lastSeenNowPlaying map[string]Track
42
42
mu sync.Mutex
43
+
logger *log.Logger
43
44
}
44
45
45
-
func NewLastFMService(db *db.DB, apiKey string, musicBrainzService *musicbrainz.MusicBrainzService, atprotoService *atprotoauth.ATprotoAuthService) *LastFMService {
46
+
func NewLastFMService(db *db.DB, apiKey string, musicBrainzService *musicbrainz.MusicBrainzService, atprotoService *atprotoauth.ATprotoAuthService, playingNowService interface {
47
+
PublishPlayingNow(ctx context.Context, userID int64, track *models.Track) error
48
+
ClearPlayingNow(ctx context.Context, userID int64) error
49
+
}) *LastFMService {
50
+
logger := log.New(os.Stdout, "lastfm: ", log.LstdFlags|log.Lmsgprefix)
51
+
46
52
return &LastFMService{
47
53
db: db,
48
54
httpClient: &http.Client{
···
54
60
Usernames: make([]string, 0),
55
61
atprotoService: atprotoService,
56
62
musicBrainzService: musicBrainzService,
63
+
playingNowService: playingNowService,
57
64
lastSeenNowPlaying: make(map[string]Track),
58
65
mu: sync.Mutex{},
66
+
logger: logger,
59
67
}
60
68
}
61
69
62
70
func (l *LastFMService) loadUsernames() error {
63
71
u, err := l.db.GetAllUsersWithLastFM()
64
72
if err != nil {
65
-
log.Printf("Error loading users with Last.fm from DB: %v", err)
73
+
l.logger.Printf("Error loading users with Last.fm from DB: %v", err)
66
74
return fmt.Errorf("failed to load users from database: %w", err)
67
75
}
68
76
usernames := make([]string, len(u))
···
71
79
if user.LastFMUsername != nil { // Check if the username is set
72
80
usernames[i] = *user.LastFMUsername
73
81
} else {
74
-
log.Printf("User ID %d has Last.fm enabled but no username set", user.ID)
82
+
l.logger.Printf("User ID %d has Last.fm enabled but no username set", user.ID)
75
83
}
76
84
}
77
85
···
84
92
}
85
93
86
94
l.Usernames = filteredUsernames
87
-
log.Printf("Loaded %d Last.fm usernames", len(l.Usernames))
95
+
l.logger.Printf("Loaded %d Last.fm usernames", len(l.Usernames))
88
96
89
97
return nil
90
98
}
···
113
121
return nil, fmt.Errorf("failed to create request for %s: %w", username, err)
114
122
}
115
123
116
-
log.Printf("Fetching recent tracks for user: %s", username)
124
+
l.logger.Printf("Fetching recent tracks for user: %s", username)
117
125
resp, err := l.httpClient.Do(req)
118
126
if err != nil {
119
127
return nil, fmt.Errorf("failed to fetch recent tracks for %s: %w", username, err)
···
134
142
}
135
143
if err := json.Unmarshal(bodyBytes, &recentTracksResp); err != nil {
136
144
// Log the body content that failed to decode
137
-
log.Printf("Failed to decode response body for %s: %s", username, string(bodyBytes))
145
+
l.logger.Printf("Failed to decode response body for %s: %s", username, string(bodyBytes))
138
146
return nil, fmt.Errorf("failed to decode response for %s: %w", username, err)
139
147
}
140
148
141
149
if len(recentTracksResp.RecentTracks.Tracks) > 0 {
142
-
log.Printf("Fetched %d tracks for %s. Most recent: %s - %s",
150
+
l.logger.Printf("Fetched %d tracks for %s. Most recent: %s - %s",
143
151
len(recentTracksResp.RecentTracks.Tracks),
144
152
username,
145
153
recentTracksResp.RecentTracks.Tracks[0].Artist.Text,
146
154
recentTracksResp.RecentTracks.Tracks[0].Name)
147
155
} else {
148
-
log.Printf("No recent tracks found for %s", username)
156
+
l.logger.Printf("No recent tracks found for %s", username)
149
157
}
150
158
151
159
return &recentTracksResp, nil
···
153
161
154
162
func (l *LastFMService) StartListeningTracker(interval time.Duration) {
155
163
if err := l.loadUsernames(); err != nil {
156
-
log.Printf("Failed to perform initial username load: %v", err)
164
+
l.logger.Printf("Failed to perform initial username load: %v", err)
157
165
// Decide if we should proceed without initial load or return error
158
166
}
159
167
160
168
if len(l.Usernames) == 0 {
161
-
log.Println("No Last.fm users configured. Tracker will run but fetch cycles will be skipped until users are added.")
169
+
l.logger.Println("No Last.fm users configured. Tracker will run but fetch cycles will be skipped until users are added.")
162
170
} else {
163
-
log.Printf("Found %d Last.fm users.", len(l.Usernames))
171
+
l.logger.Printf("Found %d Last.fm users.", len(l.Usernames))
164
172
}
165
173
166
174
ticker := time.NewTicker(interval)
···
169
177
if len(l.Usernames) > 0 {
170
178
l.fetchAllUserTracks(context.Background())
171
179
} else {
172
-
log.Println("Skipping initial fetch cycle as no users are configured.")
180
+
l.logger.Println("Skipping initial fetch cycle as no users are configured.")
173
181
}
174
182
175
183
for {
···
177
185
case <-ticker.C:
178
186
// refresh usernames periodically from db
179
187
if err := l.loadUsernames(); err != nil {
180
-
log.Printf("Error reloading usernames in ticker: %v", err)
188
+
l.logger.Printf("Error reloading usernames in ticker: %v", err)
181
189
// Continue ticker loop even if reload fails? Or log and potentially stop?
182
190
continue // Continue for now
183
191
}
184
192
if len(l.Usernames) > 0 {
185
193
l.fetchAllUserTracks(context.Background())
186
194
} else {
187
-
log.Println("No Last.fm users configured. Skipping fetch cycle.")
195
+
l.logger.Println("No Last.fm users configured. Skipping fetch cycle.")
188
196
}
189
197
// TODO: Implement graceful shutdown using context cancellation
190
198
// case <-ctx.Done():
191
-
// log.Println("Stopping Last.fm listening tracker.")
199
+
// l.logger.Println("Stopping Last.fm listening tracker.")
192
200
// ticker.Stop()
193
201
// return
194
202
}
195
203
}
196
204
}()
197
205
198
-
log.Printf("Last.fm Listening Tracker started with interval %v", interval)
206
+
l.logger.Printf("Last.fm Listening Tracker started with interval %v", interval)
199
207
}
200
208
201
209
// fetchAllUserTracks iterates through users and fetches their tracks.
202
210
func (l *LastFMService) fetchAllUserTracks(ctx context.Context) {
203
-
log.Printf("Starting fetch cycle for %d users...", len(l.Usernames))
211
+
l.logger.Printf("Starting fetch cycle for %d users...", len(l.Usernames))
204
212
var wg sync.WaitGroup // Use WaitGroup to fetch concurrently (optional)
205
213
fetchErrors := make(chan error, len(l.Usernames)) // Channel for errors
206
214
207
215
for _, username := range l.Usernames {
208
216
if ctx.Err() != nil {
209
-
log.Printf("Context cancelled before starting fetch for user %s.", username)
217
+
l.logger.Printf("Context cancelled before starting fetch for user %s.", username)
210
218
break // Exit loop if context is cancelled
211
219
}
212
220
···
214
222
go func(uname string) { // Launch fetch and process in a goroutine per user
215
223
defer wg.Done()
216
224
if ctx.Err() != nil {
217
-
log.Printf("Context cancelled during fetch cycle for user %s.", uname)
225
+
l.logger.Printf("Context cancelled during fetch cycle for user %s.", uname)
218
226
return // Exit goroutine if context is cancelled
219
227
}
220
228
···
223
231
const fetchLimit = 5
224
232
recentTracks, err := l.getRecentTracks(ctx, uname, fetchLimit)
225
233
if err != nil {
226
-
log.Printf("Error fetching tracks for %s: %v", uname, err)
234
+
l.logger.Printf("Error fetching tracks for %s: %v", uname, err)
227
235
fetchErrors <- fmt.Errorf("fetch failed for %s: %w", uname, err) // Report error
228
236
return
229
237
}
230
238
231
239
if recentTracks == nil || len(recentTracks.RecentTracks.Tracks) == 0 {
232
-
log.Printf("No tracks returned for user %s", uname)
240
+
l.logger.Printf("No tracks returned for user %s", uname)
233
241
return
234
242
}
235
243
236
244
// Process the fetched tracks
237
245
if err := l.processTracks(ctx, uname, recentTracks.RecentTracks.Tracks); err != nil {
238
-
log.Printf("Error processing tracks for %s: %v", uname, err)
246
+
l.logger.Printf("Error processing tracks for %s: %v", uname, err)
239
247
fetchErrors <- fmt.Errorf("process failed for %s: %w", uname, err) // Report error
240
248
}
241
249
}(username)
···
247
255
// Log any errors that occurred during the fetch cycle
248
256
errorCount := 0
249
257
for err := range fetchErrors {
250
-
log.Printf("Fetch cycle error: %v", err)
258
+
l.logger.Printf("Fetch cycle error: %v", err)
251
259
errorCount++
252
260
}
253
261
254
262
if errorCount > 0 {
255
-
log.Printf("Finished fetch cycle with %d errors.", errorCount)
263
+
l.logger.Printf("Finished fetch cycle with %d errors.", errorCount)
256
264
} else {
257
-
log.Println("Finished fetch cycle successfully.")
265
+
l.logger.Println("Finished fetch cycle successfully.")
258
266
}
259
267
}
260
268
···
274
282
}
275
283
276
284
if lastKnownTimestamp == nil {
277
-
log.Printf("no previous scrobble timestamp found for user %s. processing latest track.", username)
285
+
l.logger.Printf("no previous scrobble timestamp found for user %s. processing latest track.", username)
278
286
} else {
279
-
log.Printf("last known scrobble for %s was at %s", username, lastKnownTimestamp.Format(time.RFC3339))
287
+
l.logger.Printf("last known scrobble for %s was at %s", username, lastKnownTimestamp.Format(time.RFC3339))
280
288
}
281
289
282
290
var (
···
287
295
// handle now playing track separately
288
296
if len(tracks) > 0 && tracks[0].Attr != nil && tracks[0].Attr.NowPlaying == "true" {
289
297
nowPlayingTrack := tracks[0]
290
-
log.Printf("now playing track for %s: %s - %s", username, nowPlayingTrack.Artist.Text, nowPlayingTrack.Name)
298
+
l.logger.Printf("now playing track for %s: %s - %s", username, nowPlayingTrack.Artist.Text, nowPlayingTrack.Name)
291
299
l.mu.Lock()
292
300
lastSeen, existed := l.lastSeenNowPlaying[username]
293
301
// if our current track matches with last seen
294
302
// just compare artist/album/name for now
295
303
if existed && lastSeen.Album == nowPlayingTrack.Album && lastSeen.Name == nowPlayingTrack.Name && lastSeen.Artist == nowPlayingTrack.Artist {
296
-
log.Printf("current track matches last seen track for %s", username)
304
+
l.logger.Printf("current track matches last seen track for %s", username)
297
305
} else {
298
-
log.Printf("current track does not match last seen track for %s", username)
306
+
l.logger.Printf("current track does not match last seen track for %s", username)
299
307
// aha! we record this!
300
308
l.lastSeenNowPlaying[username] = nowPlayingTrack
309
+
310
+
// Publish playing now status
311
+
if l.playingNowService != nil {
312
+
// Convert Last.fm track to models.Track format
313
+
piperTrack := l.convertLastFMTrackToModelsTrack(nowPlayingTrack)
314
+
if err := l.playingNowService.PublishPlayingNow(ctx, user.ID, piperTrack); err != nil {
315
+
l.logger.Printf("Error publishing playing now for user %s: %v", username, err)
316
+
}
317
+
}
301
318
}
302
319
l.mu.Unlock()
320
+
} else {
321
+
// No now playing track - clear playing now status
322
+
if l.playingNowService != nil {
323
+
if err := l.playingNowService.ClearPlayingNow(ctx, user.ID); err != nil {
324
+
l.logger.Printf("Error clearing playing now for user %s: %v", username, err)
325
+
}
326
+
}
303
327
}
304
328
305
329
// find last non-now-playing track
···
312
336
}
313
337
314
338
if lastNonNowPlaying == nil {
315
-
log.Printf("no non-now-playing tracks found for user %s.", username)
339
+
l.logger.Printf("no non-now-playing tracks found for user %s.", username)
316
340
return nil
317
341
}
318
342
319
-
uts, err := strconv.ParseInt(lastNonNowPlaying.Date.UTS, 10, 64)
320
-
if err != nil {
321
-
log.Printf("error parsing timestamp '%s' for track %s - %s: %v",
322
-
lastNonNowPlaying.Date.UTS, lastNonNowPlaying.Artist.Text, lastNonNowPlaying.Name, err)
323
-
}
324
-
latestTrackTime := time.Unix(uts, 0)
343
+
latestTrackTime := lastNonNowPlaying.Date
325
344
326
345
// print both
327
-
fmt.Printf("latestTrackTime: %s\n", latestTrackTime)
328
-
fmt.Printf("lastKnownTimestamp: %s\n", lastKnownTimestamp)
346
+
l.logger.Printf("latestTrackTime: %s\n", latestTrackTime)
347
+
l.logger.Printf("lastKnownTimestamp: %s\n", lastKnownTimestamp)
329
348
330
-
if lastKnownTimestamp != nil && lastKnownTimestamp.Equal(latestTrackTime) {
331
-
log.Printf("no new tracks to process for user %s.", username)
349
+
if lastKnownTimestamp != nil && lastKnownTimestamp.Equal(latestTrackTime.Time) {
350
+
l.logger.Printf("no new tracks to process for user %s.", username)
332
351
return nil
333
352
}
334
353
335
354
for _, track := range tracks {
336
-
if track.Date == nil || track.Date.UTS == "" {
337
-
log.Printf("skipping track without timestamp for %s: %s - %s", username, track.Artist.Text, track.Name)
355
+
if track.Date == nil {
356
+
l.logger.Printf("skipping track without timestamp for %s: %s - %s", username, track.Artist.Text, track.Name)
338
357
continue
339
358
}
340
359
341
-
uts, err := strconv.ParseInt(track.Date.UTS, 10, 64)
342
-
if err != nil {
343
-
log.Printf("error parsing timestamp '%s' for track %s - %s: %v", track.Date.UTS, track.Artist.Text, track.Name, err)
344
-
continue
345
-
}
346
-
trackTime := time.Unix(uts, 0)
347
-
360
+
trackTime := track.Date.Time
348
361
// before or at last known
349
362
if lastKnownTimestamp != nil && (trackTime.Before(*lastKnownTimestamp) || trackTime.Equal(*lastKnownTimestamp)) {
350
363
if processedCount == 0 {
351
-
log.Printf("reached already known scrobbles for user %s (track time: %s, last known: %s).",
364
+
l.logger.Printf("reached already known scrobbles for user %s (track time: %s, last known: %s).",
352
365
username, trackTime.Format(time.RFC3339), lastKnownTimestamp.Format(time.RFC3339))
353
366
}
354
367
break
···
371
384
372
385
hydratedTrack, err := musicbrainz.HydrateTrack(l.musicBrainzService, baseTrack)
373
386
if err != nil {
374
-
log.Printf("error hydrating track for user %s: %s - %s: %v", username, track.Artist.Text, track.Name, err)
387
+
l.logger.Printf("error hydrating track for user %s: %s - %s: %v", username, track.Artist.Text, track.Name, err)
375
388
// we can use the track without MBIDs, it's still valid
376
389
hydratedTrack = &baseTrack
377
390
}
378
391
l.db.SaveTrack(user.ID, hydratedTrack)
379
-
log.Printf("Submitting track")
380
-
err = l.SubmitTrackToPDS(*user.ATProtoDID, hydratedTrack, ctx)
392
+
l.logger.Printf("Submitting track")
393
+
err = l.SubmitTrackToPDS(*user.ATProtoDID, *user.MostRecentAtProtoSessionID, hydratedTrack, ctx)
381
394
if err != nil {
382
-
log.Printf("error submitting track for user %s: %s - %s: %v", username, track.Artist.Text, track.Name, err)
395
+
l.logger.Printf("error submitting track for user %s: %s - %s: %v", username, track.Artist.Text, track.Name, err)
383
396
}
384
397
processedCount++
385
398
···
393
406
}
394
407
395
408
if processedCount > 0 {
396
-
log.Printf("processed %d new track(s) for user %s. latest timestamp: %s",
409
+
l.logger.Printf("processed %d new track(s) for user %s. latest timestamp: %s",
397
410
processedCount, username, latestProcessedTime.Format(time.RFC3339))
398
411
}
399
412
400
413
return nil
401
414
}
402
415
403
-
func (l *LastFMService) SubmitTrackToPDS(did string, track *models.Track, ctx context.Context) error {
404
-
client, err := l.atprotoService.GetATProtoClient()
405
-
if err != nil || client == nil {
406
-
return err
407
-
}
416
+
func (l *LastFMService) SubmitTrackToPDS(did string, mostRecentAtProtoSessionID string, track *models.Track, ctx context.Context) error {
417
+
// Use shared atproto service for submission
418
+
return atprotoservice.SubmitPlayToPDS(ctx, did, mostRecentAtProtoSessionID, track, l.atprotoService)
419
+
}
408
420
409
-
xrpcClient := l.atprotoService.GetXrpcClient()
410
-
if xrpcClient == nil {
411
-
return errors.New("xrpc client is kil")
412
-
}
413
-
414
-
// we check for client above
415
-
sess, err := l.db.GetAtprotoSession(did, ctx, *client)
416
-
if err != nil {
417
-
return fmt.Errorf("Couldn't get Atproto session: %s", err)
418
-
}
419
-
420
-
// printout the session details
421
-
fmt.Printf("Session details: %+v\n", sess)
422
-
423
-
// horrible no good very bad for now
424
-
artistArr := []string{}
425
-
artistMbIdArr := []string{}
426
-
for _, a := range track.Artist {
427
-
artistArr = append(artistArr, a.Name)
428
-
artistMbIdArr = append(artistMbIdArr, a.MBID)
429
-
}
430
-
431
-
var durationPtr *int64
432
-
if track.DurationMs > 0 {
433
-
durationSeconds := track.DurationMs / 1000
434
-
durationPtr = &durationSeconds
421
+
// convertLastFMTrackToModelsTrack converts a Last.fm Track to models.Track format
422
+
func (l *LastFMService) convertLastFMTrackToModelsTrack(track Track) *models.Track {
423
+
// Create artist array
424
+
artists := []models.Artist{
425
+
{
426
+
Name: track.Artist.Text,
427
+
// Note: Last.fm doesn't provide MBID in now playing, would need separate lookup
428
+
},
435
429
}
436
430
437
-
playedTimeStr := track.Timestamp.Format(time.RFC3339)
438
-
submissionAgent := viper.GetString("app.submission_agent")
439
-
if submissionAgent == "" {
440
-
submissionAgent = "piper/v0.0.1" // Default if not configured
441
-
}
431
+
// Set timestamp to current time for now playing
432
+
timestamp := time.Now()
442
433
443
-
// track -> tealfm track
444
-
tfmTrack := teal.AlphaFeedPlay{
445
-
LexiconTypeID: "fm.teal.alpha.feed.play", // Assuming this is the correct Lexicon ID
446
-
// tfm specifies duration in seconds
447
-
Duration: durationPtr, // Pointer required
448
-
TrackName: track.Name,
449
-
// should be unix timestamp
450
-
PlayedTime: &playedTimeStr, // Pointer required
451
-
ArtistNames: artistArr, // Slice of strings is correct
452
-
ArtistMbIds: artistMbIdArr, // Slice of strings is correct
453
-
ReleaseMbId: &track.ReleaseMBID, // Pointer required
454
-
ReleaseName: &track.Album, // Pointer required
455
-
RecordingMbId: &track.RecordingMBID, // Pointer required
456
-
SubmissionClientAgent: &submissionAgent, // Pointer required
434
+
piperTrack := &models.Track{
435
+
Name: track.Name,
436
+
Artist: artists,
437
+
Album: track.Album.Text, // Album is a struct with Text field
438
+
Timestamp: timestamp,
439
+
ServiceBaseUrl: "lastfm",
440
+
HasStamped: false, // Playing now tracks aren't stamped yet
457
441
}
458
442
459
-
input := atproto.RepoCreateRecord_Input{
460
-
Collection: "fm.teal.alpha.feed.play",
461
-
Repo: sess.DID,
462
-
Record: &lexutil.LexiconTypeDecoder{Val: &tfmTrack},
443
+
// Add URL if available
444
+
if track.URL != "" {
445
+
piperTrack.URL = track.URL
463
446
}
464
447
465
-
authArgs := db.AtpSessionToAuthArgs(sess)
466
-
fmt.Println(authArgs)
467
-
468
-
var out atproto.RepoCreateRecord_Output
469
-
if err := xrpcClient.Do(ctx, authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &out); err != nil {
470
-
return err
448
+
// Try to extract MBID if available (Last.fm sometimes provides this)
449
+
if track.MBID != "" { // MBID is capitalized
450
+
piperTrack.RecordingMBID = &track.MBID
471
451
}
472
452
473
-
// submit track to PDS
474
-
475
-
return nil
453
+
return piperTrack
476
454
}
+36
-2
service/lastfm/model.go
+36
-2
service/lastfm/model.go
···
1
1
package lastfm
2
2
3
+
import (
4
+
"encoding/json"
5
+
"strconv"
6
+
"time"
7
+
)
8
+
3
9
// Structs to represent the Last.fm API response for user.getrecenttracks
4
10
type RecentTracksResponse struct {
5
11
RecentTracks RecentTracks `json:"recenttracks"`
···
19
25
Name string `json:"name"`
20
26
URL string `json:"url"`
21
27
Date *TrackDate `json:"date,omitempty"` // Use pointer for optional fields
22
-
Attr *struct { // Custom handling for @attr.nowplaying
28
+
Attr *struct { // Custom handling for @attr.nowplaying
23
29
NowPlaying string `json:"nowplaying"` // Field name corrected to match struct tag
24
30
} `json:"@attr,omitempty"` // This captures the @attr object within the track
25
31
}
···
39
45
Text string `json:"#text"` // Album name
40
46
}
41
47
42
-
type TrackDate struct {
48
+
// ApiTrackDate This is the real structure returned from lastFM.
49
+
// Represents a date associated with a track, including both a Unix timestamp and a human-readable string.
50
+
// UTS is a Unix timestamp stored as a string.
51
+
// Text contains the human-readable date string.
52
+
type ApiTrackDate struct {
43
53
UTS string `json:"uts"` // Unix timestamp string
44
54
Text string `json:"#text"` // Human-readable date string
55
+
}
56
+
57
+
// TrackDate This is the struct we use to represent a date associated with a track.
58
+
// It is a wrapper around time.Time that implements json.Unmarshaler.
59
+
type TrackDate struct {
60
+
time.Time
61
+
}
62
+
63
+
// UnmarshalJSON Implements json.Unmarshaler.
64
+
// Parses the UTS field from the API response and converts it to a time.Time.
65
+
// The time.Time is stored in the Time field.
66
+
// The Text field is ignored since it can be parsed from the Time field if needed.
67
+
func (t *TrackDate) UnmarshalJSON(b []byte) (err error) {
68
+
var apiTrackDate ApiTrackDate
69
+
if err := json.Unmarshal(b, &apiTrackDate); err != nil {
70
+
return err
71
+
}
72
+
uts, err := strconv.ParseInt(apiTrackDate.UTS, 10, 64)
73
+
if err != nil {
74
+
return err
75
+
}
76
+
date := time.Unix(uts, 0).UTC()
77
+
t.Time = date
78
+
return
45
79
}
46
80
47
81
type TrackXMLAttr struct {
+16
-13
service/musicbrainz/musicbrainz.go
+16
-13
service/musicbrainz/musicbrainz.go
···
8
8
"log"
9
9
"net/http"
10
10
"net/url"
11
+
"os"
11
12
"sort"
12
13
"strings"
13
14
"sync" // Added for mutex
···
75
76
cacheMutex sync.RWMutex // Mutex to protect the cache
76
77
cacheTTL time.Duration // Time-to-live for cache entries
77
78
cleaner MetadataCleaner // Cleaner for cleaning up expired cache entries
79
+
logger *log.Logger // Logger for logging
78
80
}
79
81
80
82
// NewMusicBrainzService creates a new service instance with rate limiting and caching.
···
83
85
limiter := rate.NewLimiter(rate.Every(time.Second), 1)
84
86
// Set a default cache TTL (e.g., 1 hour)
85
87
defaultCacheTTL := 1 * time.Hour
86
-
88
+
logger := log.New(os.Stdout, "musicbrainz: ", log.LstdFlags|log.Lmsgprefix)
87
89
return &MusicBrainzService{
88
90
db: db,
89
91
httpClient: &http.Client{
···
94
96
cacheTTL: defaultCacheTTL, // Set the cache TTL
95
97
cleaner: *NewMetadataCleaner("Latin"), // Initialize the cleaner
96
98
// cacheMutex is zero-value ready
99
+
logger: logger,
97
100
}
98
101
}
99
102
···
119
122
params.Artist, _ = s.cleaner.CleanArtist(params.Artist)
120
123
121
124
cacheKey := generateCacheKey(params)
122
-
now := time.Now()
125
+
now := time.Now().UTC()
123
126
124
127
// --- Check Cache (Read Lock) ---
125
128
s.cacheMutex.RLock()
···
127
130
s.cacheMutex.RUnlock()
128
131
129
132
if found && now.Before(entry.expiresAt) {
130
-
log.Printf("Cache hit for MusicBrainz search: key=%s", cacheKey)
133
+
s.logger.Printf("Cache hit for MusicBrainz search: key=%s", cacheKey)
131
134
// Return the cached data directly. Consider if a deep copy is needed if callers modify results.
132
135
return entry.recordings, nil
133
136
}
134
137
// --- Cache Miss or Expired ---
135
138
if found {
136
-
log.Printf("Cache expired for MusicBrainz search: key=%s", cacheKey)
139
+
s.logger.Printf("Cache expired for MusicBrainz search: key=%s", cacheKey)
137
140
} else {
138
-
log.Printf("Cache miss for MusicBrainz search: key=%s", cacheKey)
141
+
s.logger.Printf("Cache miss for MusicBrainz search: key=%s", cacheKey)
139
142
}
140
143
141
144
// --- Proceed with API call ---
···
188
191
s.cacheMutex.Lock()
189
192
s.searchCache[cacheKey] = cacheEntry{
190
193
recordings: result.Recordings,
191
-
expiresAt: time.Now().Add(s.cacheTTL),
194
+
expiresAt: time.Now().UTC().Add(s.cacheTTL),
192
195
}
193
196
s.cacheMutex.Unlock()
194
-
log.Printf("Cached MusicBrainz search result for key=%s, TTL=%s", cacheKey, s.cacheTTL)
197
+
s.logger.Printf("Cached MusicBrainz search result for key=%s, TTL=%s", cacheKey, s.cacheTTL)
195
198
196
199
// Return the newly fetched results
197
200
return result.Recordings, nil
198
201
}
199
202
200
203
// GetBestRelease selects the 'best' release from a list based on specific criteria.
201
-
func GetBestRelease(releases []MusicBrainzRelease, trackTitle string) *MusicBrainzRelease {
204
+
func (s *MusicBrainzService) GetBestRelease(releases []MusicBrainzRelease, trackTitle string) *MusicBrainzRelease {
202
205
if len(releases) == 0 {
203
206
return nil
204
207
}
···
251
254
}
252
255
253
256
// 3. If none found, return the oldest release overall (which is the first one after sorting)
254
-
log.Printf("Could not find a suitable release for '%s', picking oldest: '%s' (%s)", trackTitle, releases[0].Title, releases[0].ID)
257
+
s.logger.Printf("Could not find a suitable release for '%s', picking oldest: '%s' (%s)", trackTitle, releases[0].Title, releases[0].ID)
255
258
r := releases[0]
256
259
return &r
257
260
}
···
279
282
}
280
283
281
284
firstResult := res[0]
282
-
firstResultAlbum := GetBestRelease(firstResult.Releases, firstResult.Title)
285
+
firstResultAlbum := mb.GetBestRelease(firstResult.Releases, firstResult.Title)
283
286
284
287
// woof. we Might not have any ISRCs!
285
288
var bestISRC string
···
293
296
artists[i] = models.Artist{
294
297
Name: a.Name,
295
298
ID: a.Artist.ID,
296
-
MBID: a.Artist.ID,
299
+
MBID: &a.Artist.ID,
297
300
}
298
301
}
299
302
···
303
306
Name: track.Name,
304
307
URL: track.URL,
305
308
ServiceBaseUrl: track.ServiceBaseUrl,
306
-
RecordingMBID: firstResult.ID,
309
+
RecordingMBID: &firstResult.ID,
307
310
Album: firstResultAlbum.Title,
308
-
ReleaseMBID: firstResultAlbum.ID,
311
+
ReleaseMBID: &firstResultAlbum.ID,
309
312
ISRC: bestISRC,
310
313
Timestamp: track.Timestamp,
311
314
ProgressMs: track.ProgressMs,
+257
service/playingnow/playingnow.go
+257
service/playingnow/playingnow.go
···
1
+
package playingnow
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"log"
7
+
"os"
8
+
"strconv"
9
+
"time"
10
+
11
+
"github.com/bluesky-social/indigo/atproto/client"
12
+
lexutil "github.com/bluesky-social/indigo/lex/util"
13
+
"github.com/spf13/viper"
14
+
15
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
16
+
"github.com/teal-fm/piper/api/teal"
17
+
"github.com/teal-fm/piper/db"
18
+
"github.com/teal-fm/piper/models"
19
+
atprotoauth "github.com/teal-fm/piper/oauth/atproto"
20
+
)
21
+
22
+
// PlayingNowService handles publishing current playing status to ATProto
23
+
type PlayingNowService struct {
24
+
db *db.DB
25
+
atprotoService *atprotoauth.ATprotoAuthService
26
+
logger *log.Logger
27
+
}
28
+
29
+
// NewPlayingNowService creates a new playing now service
30
+
func NewPlayingNowService(database *db.DB, atprotoService *atprotoauth.ATprotoAuthService) *PlayingNowService {
31
+
logger := log.New(os.Stdout, "playingnow: ", log.LstdFlags|log.Lmsgprefix)
32
+
33
+
return &PlayingNowService{
34
+
db: database,
35
+
atprotoService: atprotoService,
36
+
logger: logger,
37
+
}
38
+
}
39
+
40
+
// PublishPlayingNow publishes a currently playing track as actor status
41
+
func (p *PlayingNowService) PublishPlayingNow(ctx context.Context, userID int64, track *models.Track) error {
42
+
// Get user information to find their DID
43
+
user, err := p.db.GetUserByID(userID)
44
+
if err != nil {
45
+
return fmt.Errorf("failed to get user: %w", err)
46
+
}
47
+
48
+
if user.ATProtoDID == nil {
49
+
p.logger.Printf("User %d has no ATProto DID, skipping playing now", userID)
50
+
return nil
51
+
}
52
+
53
+
did := *user.ATProtoDID
54
+
55
+
// Get ATProto atProtoClient
56
+
atProtoClient, err := p.atprotoService.GetATProtoClient(did, *user.MostRecentAtProtoSessionID, ctx)
57
+
if err != nil || atProtoClient == nil {
58
+
return fmt.Errorf("failed to get ATProto atProtoClient: %w", err)
59
+
}
60
+
61
+
// Convert track to PlayView format
62
+
playView, err := p.trackToPlayView(track)
63
+
if err != nil {
64
+
return fmt.Errorf("failed to convert track to PlayView: %w", err)
65
+
}
66
+
67
+
// Create actor status record
68
+
now := time.Now()
69
+
expiry := now.Add(10 * time.Minute) // Default 10 minutes as mentioned in schema
70
+
71
+
status := &teal.AlphaActorStatus{
72
+
LexiconTypeID: "fm.teal.alpha.actor.status",
73
+
Time: strconv.FormatInt(now.Unix(), 10),
74
+
Expiry: func() *string { s := strconv.FormatInt(expiry.Unix(), 10); return &s }(),
75
+
Item: playView,
76
+
}
77
+
78
+
var swapRecord *string
79
+
swapRecord, err = p.getStatusSwapRecord(ctx, atProtoClient)
80
+
if err != nil {
81
+
return err
82
+
}
83
+
84
+
// Create the record input
85
+
input := comatproto.RepoPutRecord_Input{
86
+
Collection: "fm.teal.alpha.actor.status",
87
+
Repo: atProtoClient.AccountDID.String(),
88
+
Rkey: "self", // Use "self" as the record key for current status
89
+
Record: &lexutil.LexiconTypeDecoder{Val: status},
90
+
SwapRecord: swapRecord,
91
+
}
92
+
93
+
// Submit to PDS
94
+
if _, err := comatproto.RepoPutRecord(ctx, atProtoClient, &input); err != nil {
95
+
p.logger.Printf("Error creating playing now status for DID %s: %v", did, err)
96
+
return fmt.Errorf("failed to create playing now status for DID %s: %w", did, err)
97
+
}
98
+
99
+
p.logger.Printf("Successfully published playing now status for user %d (DID: %s): %s - %s",
100
+
userID, did, track.Artist[0].Name, track.Name)
101
+
102
+
return nil
103
+
}
104
+
105
+
// ClearPlayingNow removes the current playing status by setting an expired status
106
+
func (p *PlayingNowService) ClearPlayingNow(ctx context.Context, userID int64) error {
107
+
// Get user information
108
+
user, err := p.db.GetUserByID(userID)
109
+
if err != nil {
110
+
return fmt.Errorf("failed to get user: %w", err)
111
+
}
112
+
113
+
if user.ATProtoDID == nil {
114
+
p.logger.Printf("User %d has no ATProto DID, skipping clear playing now", userID)
115
+
return nil
116
+
}
117
+
118
+
did := *user.ATProtoDID
119
+
120
+
// Get ATProto clients
121
+
atProtoClient, err := p.atprotoService.GetATProtoClient(did, *user.MostRecentAtProtoSessionID, ctx)
122
+
if err != nil || atProtoClient == nil {
123
+
return fmt.Errorf("failed to get ATProto atProtoClient: %w", err)
124
+
}
125
+
126
+
// Create an expired status (essentially clearing it)
127
+
now := time.Now()
128
+
expiredTime := now.Add(-1 * time.Minute) // Set expiry to 1 minute ago
129
+
130
+
// Create empty play view
131
+
emptyPlayView := &teal.AlphaFeedDefs_PlayView{
132
+
TrackName: "", // Empty track indicates no current playing
133
+
Artists: []*teal.AlphaFeedDefs_Artist{},
134
+
}
135
+
136
+
status := &teal.AlphaActorStatus{
137
+
LexiconTypeID: "fm.teal.alpha.actor.status",
138
+
Time: strconv.FormatInt(now.Unix(), 10),
139
+
Expiry: func() *string { s := strconv.FormatInt(expiredTime.Unix(), 10); return &s }(),
140
+
Item: emptyPlayView,
141
+
}
142
+
143
+
var swapRecord *string
144
+
swapRecord, err = p.getStatusSwapRecord(ctx, atProtoClient)
145
+
if err != nil {
146
+
return err
147
+
}
148
+
149
+
// Update the record
150
+
input := comatproto.RepoPutRecord_Input{
151
+
Collection: "fm.teal.alpha.actor.status",
152
+
Repo: atProtoClient.AccountDID.String(),
153
+
Rkey: "self",
154
+
Record: &lexutil.LexiconTypeDecoder{Val: status},
155
+
SwapRecord: swapRecord,
156
+
}
157
+
158
+
if _, err := comatproto.RepoPutRecord(ctx, atProtoClient, &input); err != nil {
159
+
p.logger.Printf("Error clearing playing now status for DID %s: %v", did, err)
160
+
return fmt.Errorf("failed to clear playing now status for DID %s: %w", did, err)
161
+
}
162
+
163
+
p.logger.Printf("Successfully cleared playing now status for user %d (DID: %s)", userID, did)
164
+
return nil
165
+
}
166
+
167
+
// trackToPlayView converts a models.Track to teal.AlphaFeedDefs_PlayView
168
+
func (p *PlayingNowService) trackToPlayView(track *models.Track) (*teal.AlphaFeedDefs_PlayView, error) {
169
+
if track.Name == "" {
170
+
return nil, fmt.Errorf("track name cannot be empty")
171
+
}
172
+
173
+
// Convert artists
174
+
artists := make([]*teal.AlphaFeedDefs_Artist, 0, len(track.Artist))
175
+
for _, a := range track.Artist {
176
+
artist := &teal.AlphaFeedDefs_Artist{
177
+
ArtistName: a.Name,
178
+
ArtistMbId: a.MBID,
179
+
}
180
+
artists = append(artists, artist)
181
+
}
182
+
183
+
// Prepare optional fields
184
+
var durationPtr *int64
185
+
if track.DurationMs > 0 {
186
+
durationSeconds := track.DurationMs / 1000
187
+
durationPtr = &durationSeconds
188
+
}
189
+
190
+
var playedTimeStr *string
191
+
if !track.Timestamp.IsZero() {
192
+
timeStr := track.Timestamp.Format(time.RFC3339)
193
+
playedTimeStr = &timeStr
194
+
}
195
+
196
+
var isrcPtr *string
197
+
if track.ISRC != "" {
198
+
isrcPtr = &track.ISRC
199
+
}
200
+
201
+
var originUrlPtr *string
202
+
if track.URL != "" {
203
+
originUrlPtr = &track.URL
204
+
}
205
+
206
+
var servicePtr *string
207
+
if track.ServiceBaseUrl != "" {
208
+
servicePtr = &track.ServiceBaseUrl
209
+
}
210
+
211
+
var releaseNamePtr *string
212
+
if track.Album != "" {
213
+
releaseNamePtr = &track.Album
214
+
}
215
+
216
+
// Get submission client agent
217
+
submissionAgent := viper.GetString("app.submission_agent")
218
+
if submissionAgent == "" {
219
+
submissionAgent = "piper/v0.0.2"
220
+
}
221
+
222
+
playView := &teal.AlphaFeedDefs_PlayView{
223
+
TrackName: track.Name,
224
+
Artists: artists,
225
+
Duration: durationPtr,
226
+
PlayedTime: playedTimeStr,
227
+
RecordingMbId: track.RecordingMBID,
228
+
ReleaseMbId: track.ReleaseMBID,
229
+
ReleaseName: releaseNamePtr,
230
+
Isrc: isrcPtr,
231
+
OriginUrl: originUrlPtr,
232
+
MusicServiceBaseDomain: servicePtr,
233
+
SubmissionClientAgent: &submissionAgent,
234
+
}
235
+
236
+
return playView, nil
237
+
}
238
+
239
+
// getStatusSwapRecord retrieves the current swap record (CID) for the actor status record.
240
+
// Returns (nil, nil) if the record does not exist yet.
241
+
func (p *PlayingNowService) getStatusSwapRecord(ctx context.Context, atApiClient *client.APIClient) (*string, error) {
242
+
result, err := comatproto.RepoGetRecord(ctx, atApiClient, "", "fm.teal.alpha.actor.status", atApiClient.AccountDID.String(), "self")
243
+
244
+
if err != nil {
245
+
xErr, ok := err.(*client.APIError)
246
+
if !ok {
247
+
return nil, fmt.Errorf("error getting the record: %w", err)
248
+
}
249
+
if xErr.StatusCode == 400 { // 400 means not found in this API, which would be the case if the record does not exist yet
250
+
return nil, nil
251
+
}
252
+
253
+
return nil, fmt.Errorf("error getting the record: %w", err)
254
+
255
+
}
256
+
return result.Cid, nil
257
+
}
+144
service/playingnow/playingnow_test.go
+144
service/playingnow/playingnow_test.go
···
1
+
package playingnow
2
+
3
+
import (
4
+
"testing"
5
+
"time"
6
+
7
+
"github.com/teal-fm/piper/db"
8
+
"github.com/teal-fm/piper/models"
9
+
)
10
+
11
+
func TestTrackToPlayView(t *testing.T) {
12
+
// Create a mock playing now service (we'll test the conversion logic)
13
+
database, err := db.New(":memory:")
14
+
if err != nil {
15
+
t.Fatalf("Failed to create test database: %v", err)
16
+
}
17
+
defer database.Close()
18
+
19
+
if err := database.Initialize(); err != nil {
20
+
t.Fatalf("Failed to initialize test database: %v", err)
21
+
}
22
+
23
+
// Mock ATProto service (we'll just test the conversion, not the actual submission)
24
+
service := &PlayingNowService{
25
+
db: database,
26
+
logger: nil, // We'll skip logging in tests
27
+
}
28
+
29
+
// Create a test track
30
+
track := &models.Track{
31
+
Name: "Test Track",
32
+
Artist: []models.Artist{
33
+
{
34
+
Name: "Test Artist",
35
+
MBID: func() *string { s := "test-artist-mbid"; return &s }(),
36
+
},
37
+
},
38
+
Album: "Test Album",
39
+
DurationMs: 240000, // 4 minutes
40
+
Timestamp: time.Now(),
41
+
ServiceBaseUrl: "spotify",
42
+
URL: "https://open.spotify.com/track/test",
43
+
RecordingMBID: func() *string { s := "test-recording-mbid"; return &s }(),
44
+
ReleaseMBID: func() *string { s := "test-release-mbid"; return &s }(),
45
+
ISRC: "TEST1234567",
46
+
}
47
+
48
+
// Test the conversion
49
+
playView, err := service.trackToPlayView(track)
50
+
if err != nil {
51
+
t.Fatalf("Failed to convert track to PlayView: %v", err)
52
+
}
53
+
54
+
// Verify the conversion
55
+
if playView.TrackName != "Test Track" {
56
+
t.Errorf("Expected track name 'Test Track', got %s", playView.TrackName)
57
+
}
58
+
59
+
if len(playView.Artists) != 1 {
60
+
t.Errorf("Expected 1 artist, got %d", len(playView.Artists))
61
+
} else {
62
+
if playView.Artists[0].ArtistName != "Test Artist" {
63
+
t.Errorf("Expected artist name 'Test Artist', got %s", playView.Artists[0].ArtistName)
64
+
}
65
+
if playView.Artists[0].ArtistMbId == nil || *playView.Artists[0].ArtistMbId != "test-artist-mbid" {
66
+
t.Errorf("Artist MBID not set correctly")
67
+
}
68
+
}
69
+
70
+
if playView.ReleaseName == nil || *playView.ReleaseName != "Test Album" {
71
+
t.Errorf("Release name not set correctly")
72
+
}
73
+
74
+
if playView.Duration == nil || *playView.Duration != 240 {
75
+
t.Errorf("Expected duration 240 seconds, got %v", playView.Duration)
76
+
}
77
+
78
+
if playView.RecordingMbId == nil || *playView.RecordingMbId != "test-recording-mbid" {
79
+
t.Errorf("Recording MBID not set correctly")
80
+
}
81
+
82
+
if playView.ReleaseMbId == nil || *playView.ReleaseMbId != "test-release-mbid" {
83
+
t.Errorf("Release MBID not set correctly")
84
+
}
85
+
86
+
if playView.Isrc == nil || *playView.Isrc != "TEST1234567" {
87
+
t.Errorf("ISRC not set correctly")
88
+
}
89
+
90
+
if playView.OriginUrl == nil || *playView.OriginUrl != "https://open.spotify.com/track/test" {
91
+
t.Errorf("Origin URL not set correctly")
92
+
}
93
+
94
+
if playView.MusicServiceBaseDomain == nil || *playView.MusicServiceBaseDomain != "spotify" {
95
+
t.Errorf("Music service not set correctly")
96
+
}
97
+
}
98
+
99
+
func TestTrackToPlayViewEmptyTrack(t *testing.T) {
100
+
service := &PlayingNowService{}
101
+
102
+
// Test with empty track name (should fail)
103
+
track := &models.Track{
104
+
Name: "", // Empty name should cause error
105
+
Artist: []models.Artist{{Name: "Test Artist"}},
106
+
}
107
+
108
+
_, err := service.trackToPlayView(track)
109
+
if err == nil {
110
+
t.Error("Expected error for empty track name, got nil")
111
+
}
112
+
}
113
+
114
+
func TestTrackToPlayViewMinimal(t *testing.T) {
115
+
service := &PlayingNowService{}
116
+
117
+
// Test with minimal track data
118
+
track := &models.Track{
119
+
Name: "Minimal Track",
120
+
Artist: []models.Artist{{Name: "Minimal Artist"}},
121
+
}
122
+
123
+
playView, err := service.trackToPlayView(track)
124
+
if err != nil {
125
+
t.Fatalf("Failed to convert minimal track: %v", err)
126
+
}
127
+
128
+
if playView.TrackName != "Minimal Track" {
129
+
t.Errorf("Expected track name 'Minimal Track', got %s", playView.TrackName)
130
+
}
131
+
132
+
if len(playView.Artists) != 1 || playView.Artists[0].ArtistName != "Minimal Artist" {
133
+
t.Errorf("Artist not set correctly")
134
+
}
135
+
136
+
// Optional fields should be nil for minimal track
137
+
if playView.Duration != nil {
138
+
t.Errorf("Expected duration to be nil for minimal track")
139
+
}
140
+
141
+
if playView.ReleaseName != nil {
142
+
t.Errorf("Expected release name to be nil for minimal track")
143
+
}
144
+
}
+269
-217
service/spotify/spotify.go
+269
-217
service/spotify/spotify.go
···
9
9
"log"
10
10
"net/http"
11
11
"net/url"
12
+
"os"
12
13
"strings"
13
14
"sync"
14
15
"time"
15
16
16
17
"context" // Added for context.Context
17
18
18
-
"github.com/bluesky-social/indigo/api/atproto" // Added for atproto.RepoCreateRecord_Input
19
-
lexutil "github.com/bluesky-social/indigo/lex/util" // Added for lexutil.LexiconTypeDecoder
20
-
"github.com/bluesky-social/indigo/xrpc" // Added for xrpc.Client
21
-
"github.com/spf13/viper"
22
-
"github.com/teal-fm/piper/api/teal" // Added for teal.AlphaFeedPlay
19
+
// Added for atproto.RepoCreateRecord_Input
20
+
// Added for lexutil.LexiconTypeDecoder
21
+
// Added for xrpc.Client
22
+
"github.com/spf13/viper" // Added for teal.AlphaFeedPlay
23
23
"github.com/teal-fm/piper/db"
24
24
"github.com/teal-fm/piper/models"
25
25
atprotoauth "github.com/teal-fm/piper/oauth/atproto"
26
+
atprotoservice "github.com/teal-fm/piper/service/atproto"
26
27
"github.com/teal-fm/piper/service/musicbrainz"
27
28
"github.com/teal-fm/piper/session"
28
29
)
29
30
30
31
type SpotifyService struct {
31
-
DB *db.DB
32
-
atprotoService *atprotoauth.ATprotoAuthService // Added field
33
-
mb *musicbrainz.MusicBrainzService // Added field
34
-
userTracks map[int64]*models.Track
35
-
userTokens map[int64]string
36
-
mu sync.RWMutex
32
+
DB *db.DB
33
+
atprotoService *atprotoauth.ATprotoAuthService // Added field
34
+
mb *musicbrainz.MusicBrainzService // Added field
35
+
playingNowService interface {
36
+
PublishPlayingNow(ctx context.Context, userID int64, track *models.Track) error
37
+
ClearPlayingNow(ctx context.Context, userID int64) error
38
+
} // Added field for playing now service
39
+
userTracks map[int64]*models.Track
40
+
userTokens map[int64]string
41
+
mu sync.RWMutex
42
+
logger *log.Logger
37
43
}
38
44
39
-
func NewSpotifyService(database *db.DB, atprotoService *atprotoauth.ATprotoAuthService, musicBrainzService *musicbrainz.MusicBrainzService) *SpotifyService {
45
+
func NewSpotifyService(database *db.DB, atprotoService *atprotoauth.ATprotoAuthService, musicBrainzService *musicbrainz.MusicBrainzService, playingNowService interface {
46
+
PublishPlayingNow(ctx context.Context, userID int64, track *models.Track) error
47
+
ClearPlayingNow(ctx context.Context, userID int64) error
48
+
}) *SpotifyService {
49
+
logger := log.New(os.Stdout, "spotify: ", log.LstdFlags|log.Lmsgprefix)
50
+
40
51
return &SpotifyService{
41
-
DB: database,
42
-
atprotoService: atprotoService,
43
-
mb: musicBrainzService,
44
-
userTracks: make(map[int64]*models.Track),
45
-
userTokens: make(map[int64]string),
52
+
DB: database,
53
+
atprotoService: atprotoService,
54
+
mb: musicBrainzService,
55
+
playingNowService: playingNowService,
56
+
userTracks: make(map[int64]*models.Track),
57
+
userTokens: make(map[int64]string),
58
+
logger: logger,
46
59
}
47
60
}
48
61
49
-
func (s *SpotifyService) SubmitTrackToPDS(did string, track *models.Track, ctx context.Context) error {
50
-
client, err := s.atprotoService.GetATProtoClient()
51
-
if err != nil || client == nil {
52
-
log.Printf("Error getting ATProto client: %v", err)
53
-
return fmt.Errorf("failed to get ATProto client: %w", err)
54
-
}
55
-
56
-
xrpcClient := s.atprotoService.GetXrpcClient()
57
-
if xrpcClient == nil {
58
-
return errors.New("xrpc client is not available")
59
-
}
60
-
61
-
sess, err := s.DB.GetAtprotoSession(did, ctx, *client)
62
-
if err != nil {
63
-
return fmt.Errorf("couldn't get Atproto session for DID %s: %w", did, err)
64
-
}
65
-
66
-
artistArr := make([]string, 0, len(track.Artist))
67
-
artistMbIdArr := make([]string, 0, len(track.Artist))
68
-
for _, a := range track.Artist {
69
-
artistArr = append(artistArr, a.Name)
70
-
artistMbIdArr = append(artistMbIdArr, a.MBID)
62
+
func (s *SpotifyService) SubmitTrackToPDS(did string, mostRecentAtProtoSessionID string, track *models.Track, ctx context.Context) error {
63
+
//Had a empty feed.play get submitted not sure why. Tracking here
64
+
if track.Name == "" {
65
+
s.logger.Println("Track name is empty. Skipping submission. Please record the logs before and send to the teal.fm Discord")
66
+
return nil
71
67
}
72
68
73
-
var durationPtr *int64
74
-
if track.DurationMs > 0 {
75
-
durationSeconds := track.DurationMs / 1000
76
-
durationPtr = &durationSeconds
77
-
}
78
-
79
-
playedTimeStr := track.Timestamp.Format(time.RFC3339)
80
-
submissionAgent := viper.GetString("app.submission_agent")
81
-
if submissionAgent == "" {
82
-
submissionAgent = "piper/v0.0.1" // Default if not configured
83
-
}
84
-
85
-
tfmTrack := teal.AlphaFeedPlay{
86
-
LexiconTypeID: "fm.teal.alpha.feed.play",
87
-
Duration: durationPtr,
88
-
TrackName: track.Name,
89
-
PlayedTime: &playedTimeStr,
90
-
ArtistNames: artistArr,
91
-
ArtistMbIds: artistMbIdArr,
92
-
ReleaseMbId: &track.ReleaseMBID,
93
-
ReleaseName: &track.Album,
94
-
RecordingMbId: &track.RecordingMBID,
95
-
// Optional: Spotify specific data if your lexicon supports it
96
-
// SpotifyTrackID: &track.ServiceID,
97
-
// SpotifyAlbumID: &track.ServiceAlbumID,
98
-
// SpotifyArtistIDs: track.ServiceArtistIDs, // Assuming this is a []string
99
-
SubmissionClientAgent: &submissionAgent,
100
-
}
101
-
102
-
input := atproto.RepoCreateRecord_Input{
103
-
Collection: "fm.teal.alpha.feed.play", // Ensure this collection is correct
104
-
Repo: sess.DID,
105
-
Record: &lexutil.LexiconTypeDecoder{Val: &tfmTrack},
106
-
}
107
-
108
-
authArgs := db.AtpSessionToAuthArgs(sess)
109
-
110
-
var out atproto.RepoCreateRecord_Output
111
-
if err := xrpcClient.Do(ctx, authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &out); err != nil {
112
-
log.Printf("Error creating record for DID %s: %v. Input: %+v", did, err, input)
113
-
return fmt.Errorf("failed to create record on PDS for DID %s: %w", did, err)
114
-
}
115
-
116
-
log.Printf("Successfully submitted track '%s' to PDS for DID %s. Record URI: %s", track.Name, did, out.Uri)
117
-
return nil
69
+
// Use shared atproto service for submission
70
+
return atprotoservice.SubmitPlayToPDS(ctx, did, mostRecentAtProtoSessionID, track, s.atprotoService)
118
71
}
119
72
120
-
func (s *SpotifyService) SetAccessToken(token string, userId int64, hasSession bool) (int64, error) {
121
-
userID, err := s.identifyAndStoreUser(token, userId, hasSession)
73
+
func (s *SpotifyService) SetAccessToken(token string, refreshToken string, userId int64, hasSession bool) (int64, error) {
74
+
userID, err := s.identifyAndStoreUser(token, refreshToken, userId, hasSession)
122
75
if err != nil {
123
-
log.Printf("Error identifying and storing user: %v", err)
76
+
s.logger.Printf("Error identifying and storing user: %v", err)
124
77
return 0, err
125
78
}
126
79
return userID, nil
127
80
}
128
81
129
-
func (s *SpotifyService) identifyAndStoreUser(token string, userId int64, hasSession bool) (int64, error) {
82
+
func (s *SpotifyService) identifyAndStoreUser(token string, refreshToken string, userId int64, hasSession bool) (int64, error) {
130
83
userProfile, err := s.fetchSpotifyProfile(token)
131
84
if err != nil {
132
-
log.Printf("Error fetching Spotify profile: %v", err)
85
+
s.logger.Printf("Error fetching Spotify profile: %v", err)
133
86
return 0, err
134
87
}
135
88
136
-
fmt.Printf("uid: %d hasSession: %t", userId, hasSession)
89
+
s.logger.Printf("uid: %d hasSession: %t", userId, hasSession)
137
90
138
91
user, err := s.DB.GetUserBySpotifyID(userProfile.ID)
139
92
if err != nil {
140
93
// This error might mean DB connection issue, not just user not found.
141
-
log.Printf("Error checking for user by Spotify ID %s: %v", userProfile.ID, err)
94
+
s.logger.Printf("Error checking for user by Spotify ID %s: %v", userProfile.ID, err)
142
95
return 0, err
143
96
}
144
97
145
-
tokenExpiryTime := time.Now().Add(1 * time.Hour) // Spotify tokens last ~1 hour
98
+
tokenExpiryTime := time.Now().UTC().Add(1 * time.Hour) // Spotify tokens last ~1 hour
146
99
147
100
// We don't intend users to log in via spotify!
148
101
if user == nil {
149
102
if !hasSession {
150
-
log.Printf("User does not seem to exist")
103
+
s.logger.Printf("User does not seem to exist")
151
104
return 0, fmt.Errorf("user does not seem to exist")
152
105
} else {
153
106
// overwrite prev user
154
-
user, err = s.DB.AddSpotifySession(userId, userProfile.DisplayName, userProfile.Email, userProfile.ID, token, "", tokenExpiryTime)
107
+
user, err = s.DB.AddSpotifySession(userId, userProfile.DisplayName, userProfile.Email, userProfile.ID, token, refreshToken, tokenExpiryTime)
155
108
if err != nil {
156
-
log.Printf("Error adding Spotify session for user ID %d: %v", userId, err)
109
+
s.logger.Printf("Error adding Spotify session for user ID %d: %v", userId, err)
157
110
return 0, err
158
111
}
159
112
}
160
113
} else {
161
-
err = s.DB.UpdateUserToken(user.ID, token, "", tokenExpiryTime)
114
+
err = s.DB.UpdateUserToken(user.ID, token, refreshToken, tokenExpiryTime)
162
115
if err != nil {
163
116
// for now log and continue
164
-
log.Printf("Error updating user token for user ID %d: %v", user.ID, err)
117
+
s.logger.Printf("Error updating user token for user ID %d: %v", user.ID, err)
165
118
} else {
166
-
log.Printf("Updated token for existing user: %s (ID: %d)", *user.Username, user.ID)
119
+
s.logger.Printf("Updated token for existing user: %s (ID: %d)", *user.Username, user.ID)
167
120
}
168
121
}
169
122
user.AccessToken = &token
···
173
126
s.userTokens[user.ID] = token
174
127
s.mu.Unlock()
175
128
176
-
log.Printf("User authenticated via Spotify: %s (ID: %d)", *user.Username, user.ID)
129
+
s.logger.Printf("User authenticated via Spotify: %s (ID: %d)", *user.Username, user.ID)
177
130
return user.ID, nil
178
131
}
179
132
···
195
148
count := 0
196
149
for _, user := range users {
197
150
// load users with valid tokens
198
-
if user.AccessToken != nil && user.TokenExpiry.After(time.Now()) {
151
+
if user.AccessToken != nil && user.TokenExpiry.After(time.Now().UTC()) {
199
152
s.userTokens[user.ID] = *user.AccessToken
200
153
count++
154
+
} else {
155
+
// Unlock so the refreshTokenInner method can lock to refresh tokens if needed
156
+
s.mu.Unlock()
157
+
//We do not need to use the output of refreshTokenInner since it is added to the list inside the function
158
+
_, err := s.refreshTokenInner(user.ID)
159
+
if err != nil {
160
+
//Probably should remove the access token and refresh in long run?
161
+
s.logger.Printf("Error refreshing token for user %d: %v", user.ID, err)
162
+
s.mu.Lock()
163
+
continue
164
+
}
165
+
count++
166
+
s.mu.Lock()
201
167
}
202
168
}
169
+
s.logger.Printf("Loaded %d active users with valid tokens", count)
170
+
return nil
171
+
}
203
172
204
-
log.Printf("Loaded %d active users with valid tokens", count)
173
+
func (s *SpotifyService) UnloadAllUsers() error {
174
+
s.mu.Lock()
175
+
defer s.mu.Unlock()
176
+
s.userTokens = make(map[int64]string)
205
177
return nil
206
178
}
207
179
···
262
234
delete(s.userTokens, userID)
263
235
s.mu.Unlock()
264
236
// Also clear the bad refresh token from the DB
265
-
updateErr := s.DB.UpdateUserToken(userID, "", "", time.Now()) // Clear tokens
237
+
updateErr := s.DB.UpdateUserToken(userID, "", "", time.Now().UTC()) // Clear tokens
266
238
if updateErr != nil {
267
-
log.Printf("Failed to clear bad refresh token for user %d: %v", userID, updateErr)
239
+
s.logger.Printf("Failed to clear bad refresh token for user %d: %v", userID, updateErr)
268
240
}
269
241
return "", fmt.Errorf("spotify token refresh failed (%d): %s", resp.StatusCode, string(body))
270
242
}
···
281
253
return "", fmt.Errorf("failed to decode refresh response: %w", err)
282
254
}
283
255
284
-
newExpiry := time.Now().Add(time.Duration(tokenResponse.ExpiresIn) * time.Second)
256
+
newExpiry := time.Now().UTC().Add(time.Duration(tokenResponse.ExpiresIn) * time.Second)
285
257
newRefreshToken := *user.RefreshToken // Default to old one
286
258
if tokenResponse.RefreshToken != "" {
287
259
newRefreshToken = tokenResponse.RefreshToken // Use new one if provided
···
290
262
// Update DB
291
263
if err := s.DB.UpdateUserToken(userID, tokenResponse.AccessToken, newRefreshToken, newExpiry); err != nil {
292
264
// Log error but continue, as we have the token in memory
293
-
log.Printf("Error updating user token in DB for user %d after refresh: %v", userID, err)
265
+
s.logger.Printf("Error updating user token in DB for user %d after refresh: %v", userID, err)
294
266
}
295
267
296
268
// Update in-memory cache
···
298
270
s.userTokens[userID] = tokenResponse.AccessToken
299
271
s.mu.Unlock()
300
272
301
-
log.Printf("Successfully refreshed token for user %d", userID)
273
+
s.logger.Printf("Successfully refreshed token for user %d", userID)
302
274
return tokenResponse.AccessToken, nil
303
275
}
304
276
···
313
285
func (s *SpotifyService) RefreshExpiredTokens() {
314
286
users, err := s.DB.GetUsersWithExpiredTokens()
315
287
if err != nil {
316
-
log.Printf("Error fetching users with expired tokens: %v", err)
288
+
s.logger.Printf("Error fetching users with expired tokens: %v", err)
317
289
return
318
290
}
319
291
···
328
300
329
301
if err != nil {
330
302
// just print out errors here for now
331
-
log.Printf("Error from service/spotify/spotify.go when refreshing tokens: %s", err.Error())
303
+
s.logger.Printf("Error from service/spotify/spotify.go when refreshing tokens: %s", err.Error())
332
304
}
333
305
334
306
refreshed++
335
307
}
336
308
337
309
if refreshed > 0 {
338
-
log.Printf("Refreshed tokens for %d users", refreshed)
310
+
s.logger.Printf("Refreshed tokens for %d users", refreshed)
339
311
}
340
312
}
341
313
···
396
368
tracks, err := s.DB.GetRecentTracks(userID, 20)
397
369
if err != nil {
398
370
http.Error(w, "Error retrieving track history", http.StatusInternalServerError)
399
-
log.Printf("Error retrieving track history: %v", err)
371
+
s.logger.Printf("Error retrieving track history: %v", err)
400
372
return
401
373
}
402
374
···
438
410
439
411
// oops, token expired or other client error
440
412
if resp.StatusCode == 401 && attempt == 0 { // Only refresh on 401 on the first attempt
441
-
log.Printf("Spotify token potentially expired for user %d, attempting refresh...", userID)
413
+
s.logger.Printf("Spotify token potentially expired for user %d, attempting refresh...", userID)
442
414
newAccessToken, refreshErr := s.refreshTokenInner(userID)
443
415
if refreshErr != nil {
444
-
log.Printf("Token refresh failed for user %d: %v", userID, refreshErr)
416
+
s.logger.Printf("Token refresh failed for user %d: %v", userID, refreshErr)
445
417
// No point retrying if refresh failed
446
418
return nil, fmt.Errorf("spotify token expired or invalid for user %d and refresh failed: %w", userID, refreshErr)
447
419
}
448
-
log.Printf("Token refreshed for user %d, retrying request...", userID)
420
+
s.logger.Printf("Token refreshed for user %d, retrying request...", userID)
449
421
token = newAccessToken // Update token for the next attempt
450
422
req.Header.Set("Authorization", "Bearer "+token) // Update header for retry
451
423
continue // Go to next attempt in the loop
···
499
471
} `json:"external_urls"`
500
472
DurationMs int `json:"duration_ms"`
501
473
} `json:"item"`
502
-
ProgressMS int `json:"progress_ms"`
474
+
ProgressMS int `json:"progress_ms"`
475
+
IsPlaying bool `json:"is_playing"`
503
476
}
504
477
505
478
err = json.Unmarshal(bodyBytes, &response) // Use bodyBytes here
506
479
if err != nil {
507
480
return nil, fmt.Errorf("failed to unmarshal spotify response: %w", err)
508
481
}
509
-
482
+
if response.IsPlaying == false {
483
+
return nil, nil
484
+
}
510
485
var artists []models.Artist
511
486
for _, artist := range response.Item.Artists {
512
487
artists = append(artists, models.Artist{
···
526
501
ServiceBaseUrl: "open.spotify.com",
527
502
ISRC: response.Item.ExternalIDs.ISRC,
528
503
HasStamped: false,
529
-
Timestamp: time.Now(),
504
+
Timestamp: time.Now().UTC(),
530
505
}
531
506
532
507
return track, nil
533
508
}
534
509
535
-
func (s *SpotifyService) StartListeningTracker(interval time.Duration) {
536
-
ticker := time.NewTicker(interval)
537
-
defer ticker.Stop()
510
+
func (s *SpotifyService) fetchAllUserTracks(ctx context.Context) {
511
+
// copy userIDs to avoid holding the lock too long
512
+
s.mu.RLock()
513
+
userIDs := make([]int64, 0, len(s.userTokens))
514
+
for userID := range s.userTokens {
515
+
userIDs = append(userIDs, userID)
516
+
}
517
+
s.mu.RUnlock()
538
518
539
-
for range ticker.C {
540
-
// copy userIDs to avoid holding the lock too long
541
-
s.mu.RLock()
542
-
userIDs := make([]int64, 0, len(s.userTokens))
543
-
for userID := range s.userTokens {
544
-
userIDs = append(userIDs, userID)
519
+
for _, userID := range userIDs {
520
+
if ctx.Err() != nil {
521
+
s.logger.Printf("Context cancelled before starting fetch for user id %d.", userID)
522
+
break // Exit loop if context is cancelled
545
523
}
546
-
s.mu.RUnlock()
547
524
548
-
for _, userID := range userIDs {
549
-
track, err := s.FetchCurrentTrack(userID)
550
-
if err != nil {
551
-
log.Printf("Error fetching track for user %d: %v", userID, err)
552
-
continue
553
-
}
525
+
track, err := s.FetchCurrentTrack(userID)
526
+
if err != nil {
527
+
s.logger.Printf("Error fetching track for user %d: %v", userID, err)
528
+
continue
529
+
}
554
530
555
-
if track == nil {
556
-
continue
531
+
if track == nil {
532
+
// No track currently playing - clear playing now status
533
+
if s.playingNowService != nil {
534
+
if err := s.playingNowService.ClearPlayingNow(ctx, userID); err != nil {
535
+
s.logger.Printf("Error clearing playing now for user %d: %v", userID, err)
536
+
}
557
537
}
538
+
continue
539
+
}
558
540
559
-
s.mu.RLock()
560
-
currentTrack := s.userTracks[userID]
561
-
s.mu.RUnlock()
541
+
s.mu.RLock()
542
+
currentTrack := s.userTracks[userID]
543
+
s.mu.RUnlock()
562
544
563
-
if currentTrack == nil {
564
-
currentTracks, _ := s.DB.GetRecentTracks(userID, 1)
565
-
if len(currentTracks) > 0 {
566
-
currentTrack = currentTracks[0]
567
-
}
545
+
if currentTrack == nil {
546
+
currentTracks, _ := s.DB.GetRecentTracks(userID, 1)
547
+
if len(currentTracks) > 0 {
548
+
currentTrack = currentTracks[0]
568
549
}
550
+
}
569
551
570
-
// if flagged true, we have a new track
571
-
isNewTrack := currentTrack == nil ||
572
-
currentTrack.Name != track.Name ||
573
-
// just check the first one for now
574
-
currentTrack.Artist[0].Name != track.Artist[0].Name
552
+
// if flagged true, we have a new track
553
+
isNewTrack := currentTrack == nil ||
554
+
currentTrack.Name != track.Name ||
555
+
// just check the first one for now
556
+
currentTrack.Artist[0].Name != track.Artist[0].Name
575
557
576
-
// we stamp a track iff we've played more than half (or 30 seconds whichever is greater)
577
-
isStamped := track.ProgressMs > track.DurationMs/2 && track.ProgressMs > 30000
558
+
// we stamp a track iff we've played more than half (or 30 seconds whichever is greater)
559
+
isStamped := track.ProgressMs > track.DurationMs/2 && track.ProgressMs > 30000
578
560
579
-
// if currentTrack.Timestamp minus track.Timestamp is greater than 30 seconds
580
-
isLastTrackStamped := currentTrack != nil && time.Since(currentTrack.Timestamp) > 30*time.Second &&
581
-
currentTrack.DurationMs > 30000
561
+
// if currentTrack.Timestamp minus track.Timestamp is greater than 30 seconds
562
+
isLastTrackStamped := currentTrack != nil && time.Since(currentTrack.Timestamp) > 30*time.Second &&
563
+
currentTrack.DurationMs > 30000
582
564
583
-
// just log when we stamp tracks
584
-
if isNewTrack && isLastTrackStamped && !currentTrack.HasStamped {
585
-
log.Printf("User %d stamped (previous) track: %s by %s", userID, currentTrack.Name, currentTrack.Artist)
586
-
currentTrack.HasStamped = true
587
-
if currentTrack.PlayID != 0 {
588
-
s.DB.UpdateTrack(currentTrack.PlayID, currentTrack)
565
+
// just log when we stamp tracks
566
+
if isNewTrack && isLastTrackStamped && !currentTrack.HasStamped {
567
+
artistName := "Unknown Artist"
568
+
if len(currentTrack.Artist) > 0 {
569
+
artistName = currentTrack.Artist[0].Name
570
+
}
571
+
s.logger.Printf("User %d stamped (previous) track: %s by %s", userID, currentTrack.Name, artistName)
572
+
currentTrack.HasStamped = true
573
+
if currentTrack.PlayID != 0 {
574
+
s.DB.UpdateTrack(currentTrack.PlayID, currentTrack)
589
575
590
-
log.Printf("Updated!")
591
-
}
576
+
s.logger.Printf("Updated!")
592
577
}
578
+
}
593
579
594
-
if isStamped && !currentTrack.HasStamped {
595
-
log.Printf("User %d stamped track: %s by %s", userID, track.Name, track.Artist)
596
-
track.HasStamped = true
597
-
// if currenttrack has a playid and the last track is the same as the current track
598
-
if !isNewTrack && currentTrack.PlayID != 0 {
599
-
s.DB.UpdateTrack(currentTrack.PlayID, track)
580
+
if isStamped && currentTrack != nil && !currentTrack.HasStamped {
581
+
artistName := "Unknown Artist"
582
+
if len(track.Artist) > 0 {
583
+
artistName = track.Artist[0].Name
584
+
}
585
+
s.logger.Printf("User %d stamped track: %s by %s", userID, track.Name, artistName)
586
+
track.HasStamped = true
587
+
// if currenttrack has a playid and the last track is the same as the current track
588
+
if !isNewTrack && currentTrack.PlayID != 0 {
589
+
s.DB.UpdateTrack(currentTrack.PlayID, track)
600
590
601
-
// Update in memory
602
-
s.mu.Lock()
603
-
s.userTracks[userID] = track
604
-
s.mu.Unlock()
591
+
// Update in memory
592
+
s.mu.Lock()
593
+
s.userTracks[userID] = track
594
+
s.mu.Unlock()
605
595
606
-
log.Printf("Updated!")
596
+
// Update playing now status since track progress changed
597
+
if s.playingNowService != nil {
598
+
if err := s.playingNowService.PublishPlayingNow(ctx, userID, track); err != nil {
599
+
s.logger.Printf("Error updating playing now for user %d: %v", userID, err)
600
+
}
607
601
}
602
+
603
+
s.logger.Printf("Updated!")
608
604
}
605
+
}
609
606
610
-
if isNewTrack {
611
-
id, err := s.DB.SaveTrack(userID, track)
612
-
if err != nil {
613
-
log.Printf("Error saving track for user %d: %v", userID, err)
614
-
continue
615
-
}
607
+
if isNewTrack {
608
+
id, err := s.DB.SaveTrack(userID, track)
609
+
if err != nil {
610
+
s.logger.Printf("Error saving track for user %d: %v", userID, err)
611
+
continue
612
+
}
616
613
617
-
track.PlayID = id
614
+
track.PlayID = id
618
615
619
-
s.mu.Lock()
620
-
s.userTracks[userID] = track
621
-
s.mu.Unlock()
616
+
s.mu.Lock()
617
+
s.userTracks[userID] = track
618
+
s.mu.Unlock()
619
+
620
+
// Publish playing now status
621
+
if s.playingNowService != nil {
622
+
if err := s.playingNowService.PublishPlayingNow(ctx, userID, track); err != nil {
623
+
s.logger.Printf("Error publishing playing now for user %d: %v", userID, err)
624
+
}
625
+
}
622
626
623
-
// Submit to ATProto PDS
624
-
// The 'track' variable is *models.Track and has been saved to DB, PlayID is populated.
625
-
dbUser, errUser := s.DB.GetUserByID(userID) // Fetch user by their internal ID
626
-
if errUser != nil {
627
-
log.Printf("User %d: Error fetching user details for PDS submission: %v", userID, errUser)
628
-
} else if dbUser == nil {
629
-
log.Printf("User %d: User not found in DB. Skipping PDS submission.", userID)
630
-
} else if dbUser.ATProtoDID == nil || *dbUser.ATProtoDID == "" {
631
-
log.Printf("User %d (%d): ATProto DID not set. Skipping PDS submission for track '%s'.", userID, dbUser.ATProtoDID, track.Name)
632
-
} else {
633
-
// User has a DID, proceed with hydration and submission
634
-
var trackToSubmitToPDS *models.Track = track // Default to the original track (already *models.Track)
635
-
if s.mb != nil { // Check if MusicBrainz service is available
636
-
// musicbrainz.HydrateTrack expects models.Track as second argument, so we pass *track
637
-
// and it returns *models.Track
638
-
hydratedTrack, errHydrate := musicbrainz.HydrateTrack(s.mb, *track)
639
-
if errHydrate != nil {
640
-
log.Printf("User %d (%d): Error hydrating track '%s' with MusicBrainz: %v. Proceeding with original track data for PDS.", userID, dbUser.ATProtoDID, track.Name, errHydrate)
641
-
} else {
642
-
log.Printf("User %d (%d): Successfully hydrated track '%s' with MusicBrainz.", userID, dbUser.ATProtoDID, track.Name)
643
-
trackToSubmitToPDS = hydratedTrack // hydratedTrack is *models.Track
644
-
}
627
+
// Submit to ATProto PDS
628
+
// The 'track' variable is *models.Track and has been saved to DB, PlayID is populated.
629
+
dbUser, errUser := s.DB.GetUserByID(userID) // Fetch user by their internal ID
630
+
if errUser != nil {
631
+
s.logger.Printf("User %d: Error fetching user details for PDS submission: %v", userID, errUser)
632
+
} else if dbUser == nil {
633
+
s.logger.Printf("User %d: User not found in DB. Skipping PDS submission.", userID)
634
+
} else if dbUser.ATProtoDID == nil || *dbUser.ATProtoDID == "" {
635
+
s.logger.Printf("User %d (%d): ATProto DID not set. Skipping PDS submission for track '%s'.", userID, dbUser.ATProtoDID, track.Name)
636
+
} else {
637
+
// User has a DID, proceed with hydration and submission
638
+
var trackToSubmitToPDS *models.Track = track // Default to the original track (already *models.Track)
639
+
if s.mb != nil { // Check if MusicBrainz service is available
640
+
// musicbrainz.HydrateTrack expects models.Track as second argument, so we pass *track
641
+
// and it returns *models.Track
642
+
hydratedTrack, errHydrate := musicbrainz.HydrateTrack(s.mb, *track)
643
+
if errHydrate != nil {
644
+
s.logger.Printf("User %d (%d): Error hydrating track '%s' with MusicBrainz: %v. Proceeding with original track data for PDS.", userID, dbUser.ATProtoDID, track.Name, errHydrate)
645
645
} else {
646
-
log.Printf("User %d (%d): MusicBrainz service not configured. Proceeding with original track data for PDS.", userID, dbUser.ATProtoDID)
646
+
s.logger.Printf("User %d (%d): Successfully hydrated track '%s' with MusicBrainz.", userID, dbUser.ATProtoDID, track.Name)
647
+
trackToSubmitToPDS = hydratedTrack // hydratedTrack is *models.Track
647
648
}
649
+
} else {
650
+
s.logger.Printf("User %d (%d): MusicBrainz service not configured. Proceeding with original track data for PDS.", userID, dbUser.ATProtoDID)
651
+
}
648
652
649
-
artistName := "Unknown Artist"
650
-
if len(trackToSubmitToPDS.Artist) > 0 {
651
-
artistName = trackToSubmitToPDS.Artist[0].Name
652
-
}
653
+
artistName := "Unknown Artist"
654
+
if len(trackToSubmitToPDS.Artist) > 0 {
655
+
artistName = trackToSubmitToPDS.Artist[0].Name
656
+
}
653
657
654
-
log.Printf("User %d (%d): Attempting to submit track '%s' by %s to PDS (DID: %s)", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name, artistName, *dbUser.ATProtoDID)
655
-
// Use context.Background() for now, or pass down a context if available
656
-
if errPDS := s.SubmitTrackToPDS(*dbUser.ATProtoDID, trackToSubmitToPDS, context.Background()); errPDS != nil {
657
-
log.Printf("User %d (%d): Error submitting track '%s' to PDS: %v", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name, errPDS)
658
-
} else {
659
-
log.Printf("User %d (%d): Successfully submitted track '%s' to PDS.", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name)
660
-
}
658
+
s.logger.Printf("User %d (%d): Attempting to submit track '%s' by %s to PDS (DID: %s)", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name, artistName, *dbUser.ATProtoDID)
659
+
// Use context.Background() for now, or pass down a context if available
660
+
if errPDS := s.SubmitTrackToPDS(*dbUser.ATProtoDID, *dbUser.MostRecentAtProtoSessionID, trackToSubmitToPDS, context.Background()); errPDS != nil {
661
+
s.logger.Printf("User %d (%d): Error submitting track '%s' to PDS: %v", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name, errPDS)
662
+
} else {
663
+
s.logger.Printf("User %d (%d): Successfully submitted track '%s' to PDS.", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name)
661
664
}
662
-
// End of PDS submission block
665
+
}
666
+
// End of PDS submission block
663
667
664
-
log.Printf("User %d is listening to: %s by %s", userID, track.Name, track.Artist)
668
+
artistName := "Unknown Artist"
669
+
if len(track.Artist) > 0 {
670
+
artistName = track.Artist[0].Name
665
671
}
672
+
s.logger.Printf("User %d is listening to: %s by %s", userID, track.Name, artistName)
666
673
}
667
674
}
668
675
}
676
+
677
+
func (s *SpotifyService) StartListeningTracker(interval time.Duration) {
678
+
ticker := time.NewTicker(interval)
679
+
680
+
go func() {
681
+
if err := s.LoadAllUsers(); err != nil {
682
+
s.logger.Printf("Error loading spotify users: %v", err)
683
+
}
684
+
685
+
if len(s.userTokens) > 0 {
686
+
s.fetchAllUserTracks(context.Background())
687
+
} else {
688
+
s.logger.Printf("No users to fetch tracks for.")
689
+
}
690
+
691
+
//unloading users to save memory and make sure we get new signups
692
+
err := s.UnloadAllUsers()
693
+
if err != nil {
694
+
log.Printf("Error loading spotify users: %v", err)
695
+
}
696
+
697
+
for range ticker.C {
698
+
s.logger.Printf("Fetching tracks...")
699
+
err := s.LoadAllUsers()
700
+
if err != nil {
701
+
s.logger.Printf("Error loading spotify users: %v", err)
702
+
continue
703
+
}
704
+
if len(s.userTokens) > 0 {
705
+
s.fetchAllUserTracks(context.Background())
706
+
} else {
707
+
s.logger.Printf("No users to fetch tracks for.")
708
+
continue
709
+
}
710
+
//unloading users to save memory and make sure we get new signups
711
+
err = s.UnloadAllUsers()
712
+
if err != nil {
713
+
log.Printf("Error loading spotify users: %v", err)
714
+
}
715
+
s.logger.Printf("Finished fetch cycle suscessfully.")
716
+
717
+
}
718
+
}()
719
+
720
+
}
+25
-32
session/session.go
+25
-32
session/session.go
···
17
17
18
18
// session/session.go
19
19
type Session struct {
20
-
ID string
21
-
UserID int64
22
-
ATprotoDID string
23
-
ATprotoAccessToken string
24
-
ATprotoRefreshToken string
25
-
CreatedAt time.Time
26
-
ExpiresAt time.Time
20
+
21
+
//need to re work this. May add onto it for atproto oauth. But need to be careful about that expiresd
22
+
//Maybe a speerate oauth session store table and it has a created date? yeah do that then can look it up by session id from this table for user actions
23
+
24
+
ID string
25
+
UserID int64
26
+
ATProtoSessionID string
27
+
CreatedAt time.Time
28
+
ExpiresAt time.Time
27
29
}
28
30
29
31
type SessionManager struct {
···
38
40
_, err := database.Exec(`
39
41
CREATE TABLE IF NOT EXISTS sessions (
40
42
id TEXT PRIMARY KEY,
41
-
user_id INTEGER NOT NULL,
43
+
user_id INTEGER NOT NULL,
44
+
at_proto_session_id TEXT NOT NULL,
42
45
created_at TIMESTAMP,
43
46
expires_at TIMESTAMP,
44
47
FOREIGN KEY (user_id) REFERENCES users(id)
···
58
61
}
59
62
60
63
// create a new session for a user
61
-
func (sm *SessionManager) CreateSession(userID int64) *Session {
64
+
func (sm *SessionManager) CreateSession(userID int64, atProtoSessionId string) *Session {
62
65
sm.mu.Lock()
63
66
defer sm.mu.Unlock()
64
67
···
67
70
rand.Read(b)
68
71
sessionID := base64.URLEncoding.EncodeToString(b)
69
72
70
-
now := time.Now()
73
+
now := time.Now().UTC()
71
74
expiresAt := now.Add(24 * time.Hour) // 24-hour session
72
75
73
76
session := &Session{
74
-
ID: sessionID,
75
-
UserID: userID,
76
-
CreatedAt: now,
77
-
ExpiresAt: expiresAt,
77
+
ID: sessionID,
78
+
UserID: userID,
79
+
ATProtoSessionID: atProtoSessionId,
80
+
CreatedAt: now,
81
+
ExpiresAt: expiresAt,
78
82
}
79
83
80
84
// store session in memory
···
83
87
// store session in database if available
84
88
if sm.db != nil {
85
89
_, err := sm.db.Exec(`
86
-
INSERT INTO sessions (id, user_id, created_at, expires_at)
87
-
VALUES (?, ?, ?, ?)`,
88
-
sessionID, userID, now, expiresAt)
90
+
INSERT INTO sessions (id, user_id, at_proto_session_id, created_at, expires_at)
91
+
VALUES (?, ?, ?, ?, ?)`,
92
+
sessionID, userID, atProtoSessionId, now, expiresAt)
89
93
90
94
if err != nil {
91
95
log.Printf("Error storing session in database: %v", err)
···
104
108
105
109
if exists {
106
110
// Check if session is expired
107
-
if time.Now().After(session.ExpiresAt) {
111
+
if time.Now().UTC().After(session.ExpiresAt) {
108
112
sm.DeleteSession(sessionID)
109
113
return nil, false
110
114
}
···
116
120
session = &Session{ID: sessionID}
117
121
118
122
err := sm.db.QueryRow(`
119
-
SELECT user_id, created_at, expires_at
123
+
SELECT user_id, at_proto_session_id, created_at, expires_at
120
124
FROM sessions WHERE id = ?`, sessionID).Scan(
121
-
&session.UserID, &session.CreatedAt, &session.ExpiresAt)
125
+
&session.UserID, &session.ATProtoSessionID, &session.CreatedAt, &session.ExpiresAt)
122
126
123
127
if err != nil {
124
128
return nil, false
125
129
}
126
130
127
-
if time.Now().After(session.ExpiresAt) {
131
+
if time.Now().UTC().After(session.ExpiresAt) {
128
132
sm.DeleteSession(sessionID)
129
133
return nil, false
130
134
}
···
178
182
MaxAge: -1,
179
183
}
180
184
http.SetCookie(w, cookie)
181
-
}
182
-
183
-
func (sm *SessionManager) HandleLogout(w http.ResponseWriter, r *http.Request) {
184
-
cookie, err := r.Cookie("session")
185
-
if err == nil {
186
-
sm.DeleteSession(cookie.Value)
187
-
}
188
-
189
-
sm.ClearSessionCookie(w)
190
-
191
-
http.Redirect(w, r, "/", http.StatusSeeOther)
192
185
}
193
186
194
187
func (sm *SessionManager) GetAPIKeyManager() *apikey.ApiKeyManager {