[WIP] music platform user data scraper
teal-fm atproto

Compare changes

Choose any two refs to compare.

+2 -2
.air.toml
··· 14 follow_symlink = false 15 full_bin = "" 16 include_dir = [] 17 - include_ext = ["go", "tpl", "tmpl", "html"] 18 include_file = [] 19 kill_delay = "0s" 20 log = "build-errors.log" ··· 48 proxy_port = 0 49 50 [screen] 51 - clear_on_rebuild = false 52 keep_scroll = true
··· 14 follow_symlink = false 15 full_bin = "" 16 include_dir = [] 17 + include_ext = ["go", "tpl", "tmpl", "html", "gohtml", "css", "js"] 18 include_file = [] 19 kill_delay = "0s" 20 log = "build-errors.log" ··· 48 proxy_port = 0 49 50 [screen] 51 + clear_on_rebuild = true 52 keep_scroll = true
+2
.env.template
··· 16 ATPROTO_CLIENT_ID= 17 ATPROTO_METADATA_URL= 18 ATPROTO_CALLBACK_URL= 19 20 # Last.fm 21 LASTFM_API_KEY=
··· 16 ATPROTO_CLIENT_ID= 17 ATPROTO_METADATA_URL= 18 ATPROTO_CALLBACK_URL= 19 + ATPROTO_CLIENT_SECRET_KEY={goat key generate -t P-256} 20 + ATPROTO_CLIENT_SECRET_KEY_ID={can be whatever usually a timestamp} 21 22 # Last.fm 23 LASTFM_API_KEY=
+21 -4
Dockerfile
··· 1 - FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:latest as builder 2 3 ARG TARGETPLATFORM 4 ARG BUILDPLATFORM 5 ARG TARGETOS 6 ARG TARGETARCH 7 8 # step 1. dep cache 9 WORKDIR /app 10 ARG TARGETPLATFORM=${BUILDPLATFORM:-linux/amd64} ··· 14 # step 2. build the actual app 15 WORKDIR /app 16 COPY . . 17 - RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-w -s" -o main ./cmd 18 ARG TARGETOS=${TARGETPLATFORM%%/*} 19 ARG TARGETARCH=${TARGETPLATFORM##*/} 20 21 - FROM --platform=${TARGETPLATFORM:-linux/amd64} scratch 22 - WORKDIR /app/ 23 COPY --from=builder /app/main /app/main 24 ENTRYPOINT ["/app/main"]
··· 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 11 12 ARG TARGETPLATFORM 13 ARG BUILDPLATFORM 14 ARG TARGETOS 15 ARG TARGETARCH 16 17 + #needed for sqlite 18 + RUN apk add --update gcc musl-dev 19 + 20 # step 1. dep cache 21 WORKDIR /app 22 ARG TARGETPLATFORM=${BUILDPLATFORM:-linux/amd64} ··· 26 # step 2. build the actual app 27 WORKDIR /app 28 COPY . . 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 33 ARG TARGETOS=${TARGETPLATFORM%%/*} 34 ARG TARGETARCH=${TARGETPLATFORM##*/} 35 36 + FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine:3.21 37 + #Creates an empty /db folder for docker compose 38 + WORKDIR /db 39 + WORKDIR /app 40 COPY --from=builder /app/main /app/main 41 ENTRYPOINT ["/app/main"]
-4
Makefile
··· 25 --build-file ./lexcfg.json \ 26 ../atproto/lexicons \ 27 ./lexicons/teal 28 - 29 - .PHONY: jwtgen 30 - jwtgen: 31 - go run github.com/haileyok/atproto-oauth-golang/cmd/helper generate-jwks
··· 25 --build-file ./lexcfg.json \ 26 ../atproto/lexicons \ 27 ./lexicons/teal
+68 -4
README.md
··· 9 10 well its just a work in progress... we build in the open! 11 12 - #### development 13 14 assuming you have go installed and set up properly: 15 16 run some make scripts: 17 18 ``` 19 - make jwtgen 20 21 make dev-setup 22 ``` ··· 32 ``` 33 air 34 ``` 35 - 36 air should automatically build and run piper, and watch for changes on relevant files. 37 38 #### docker 39 40 - TODO
··· 9 10 well its just a work in progress... we build in the open! 11 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) 58 59 assuming you have go installed and set up properly: 60 61 run some make scripts: 62 63 ``` 64 65 make dev-setup 66 ``` ··· 76 ``` 77 air 78 ``` 79 air should automatically build and run piper, and watch for changes on relevant files. 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 + 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 103 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
··· 27 } 28 29 cw := cbg.NewCborWriter(w) 30 - fieldCount := 14 31 32 if t.ArtistMbIds == nil { 33 fieldCount-- 34 } 35 ··· 126 } 127 if _, err := cw.WriteString(string("fm.teal.alpha.feed.play")); err != nil { 128 return err 129 } 130 131 // t.Duration (int64) (int64) ··· 316 } 317 318 // t.ArtistNames ([]string) (slice) 319 - if len("artistNames") > 1000000 { 320 - return xerrors.Errorf("Value in field \"artistNames\" was too long") 321 - } 322 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 - } 329 330 - if len(t.ArtistNames) > 8192 { 331 - return xerrors.Errorf("Slice value in field t.ArtistNames was too long") 332 - } 333 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") 340 } 341 342 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 343 return err 344 } 345 - if _, err := cw.WriteString(string(v)); err != nil { 346 - return err 347 - } 348 349 } 350 351 // t.ReleaseMbId (string) (string) ··· 582 } 583 584 t.LexiconTypeID = string(sval) 585 } 586 // t.Duration (int64) (int64) 587 case "duration": ··· 1733 } 1734 1735 cw := cbg.NewCborWriter(w) 1736 - fieldCount := 13 1737 - 1738 - if t.ArtistMbIds == nil { 1739 - fieldCount-- 1740 - } 1741 1742 if t.Duration == nil { 1743 fieldCount-- ··· 1813 return err 1814 } 1815 } 1816 } 1817 1818 // t.Duration (int64) (int64) ··· 1966 } 1967 } 1968 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 // t.ReleaseMbId (string) (string) 2039 if t.ReleaseMbId != nil { 2040 ··· 2259 t.Isrc = (*string)(&sval) 2260 } 2261 } 2262 // t.Duration (int64) (int64) 2263 case "duration": 2264 { ··· 2369 t.PlayedTime = (*string)(&sval) 2370 } 2371 } 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 // t.ReleaseMbId (string) (string) 2453 case "releaseMbId": 2454 ··· 2565 2566 return nil 2567 }
··· 27 } 28 29 cw := cbg.NewCborWriter(w) 30 + fieldCount := 15 31 32 if t.ArtistMbIds == nil { 33 + fieldCount-- 34 + } 35 + 36 + if t.ArtistNames == nil { 37 + fieldCount-- 38 + } 39 + 40 + if t.Artists == nil { 41 fieldCount-- 42 } 43 ··· 134 } 135 if _, err := cw.WriteString(string("fm.teal.alpha.feed.play")); err != nil { 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 + } 166 } 167 168 // t.Duration (int64) (int64) ··· 353 } 354 355 // t.ArtistNames ([]string) (slice) 356 + if t.ArtistNames != nil { 357 358 + if len("artistNames") > 1000000 { 359 + return xerrors.Errorf("Value in field \"artistNames\" was too long") 360 + } 361 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 + } 368 369 + if len(t.ArtistNames) > 8192 { 370 + return xerrors.Errorf("Slice value in field t.ArtistNames was too long") 371 } 372 373 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.ArtistNames))); err != nil { 374 return err 375 } 376 + for _, v := range t.ArtistNames { 377 + if len(v) > 1000000 { 378 + return xerrors.Errorf("Value in field v was too long") 379 + } 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 + } 389 } 390 391 // t.ReleaseMbId (string) (string) ··· 622 } 623 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 + } 674 } 675 // t.Duration (int64) (int64) 676 case "duration": ··· 1822 } 1823 1824 cw := cbg.NewCborWriter(w) 1825 + fieldCount := 12 1826 1827 if t.Duration == nil { 1828 fieldCount-- ··· 1898 return err 1899 } 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 + 1927 } 1928 1929 // t.Duration (int64) (int64) ··· 2077 } 2078 } 2079 2080 // t.ReleaseMbId (string) (string) 2081 if t.ReleaseMbId != nil { 2082 ··· 2301 t.Isrc = (*string)(&sval) 2302 } 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 + } 2353 // t.Duration (int64) (int64) 2354 case "duration": 2355 { ··· 2460 t.PlayedTime = (*string)(&sval) 2461 } 2462 } 2463 // t.ReleaseMbId (string) (string) 2464 case "releaseMbId": 2465 ··· 2576 2577 return nil 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
··· 4 5 // schema: fm.teal.alpha.feed.defs 6 7 // AlphaFeedDefs_PlayView is a "playView" in the fm.teal.alpha.feed.defs schema. 8 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"` 13 // duration: The length of the track in seconds 14 Duration *int64 `json:"duration,omitempty" cborgen:"duration,omitempty"` 15 // isrc: The ISRC code associated with the recording
··· 4 5 // schema: fm.teal.alpha.feed.defs 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 + 15 // AlphaFeedDefs_PlayView is a "playView" in the fm.teal.alpha.feed.defs schema. 16 type AlphaFeedDefs_PlayView struct { 17 + // artists: Array of artists in order of original appearance. 18 + Artists []*AlphaFeedDefs_Artist `json:"artists" cborgen:"artists"` 19 // duration: The length of the track in seconds 20 Duration *int64 `json:"duration,omitempty" cborgen:"duration,omitempty"` 21 // isrc: The ISRC code associated with the recording
+5 -3
api/teal/feedplay.go
··· 14 // RECORDTYPE: AlphaFeedPlay 15 type AlphaFeedPlay struct { 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 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"` 21 // duration: The length of the track in seconds 22 Duration *int64 `json:"duration,omitempty" cborgen:"duration,omitempty"` 23 // isrc: The ISRC code associated with the recording
··· 14 // RECORDTYPE: AlphaFeedPlay 15 type AlphaFeedPlay struct { 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. Prefer using 'artists'. 18 ArtistMbIds []string `json:"artistMbIds,omitempty" cborgen:"artistMbIds,omitempty"` 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"` 23 // duration: The length of the track in seconds 24 Duration *int64 `json:"duration,omitempty" cborgen:"duration,omitempty"` 25 // isrc: The ISRC code associated with the recording
+170 -132
cmd/handlers.go
··· 8 "strconv" 9 10 "github.com/teal-fm/piper/db" 11 "github.com/teal-fm/piper/service/musicbrainz" 12 "github.com/teal-fm/piper/service/spotify" 13 "github.com/teal-fm/piper/session" 14 ) 15 16 - func home(database *db.DB) http.HandlerFunc { 17 return func(w http.ResponseWriter, r *http.Request) { 18 19 w.Header().Set("Content-Type", "text/html") ··· 31 log.Printf("Error fetching user %d details for home page: %v", userID, err) 32 } 33 } 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>` 94 } 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 - 128 } 129 - 130 - html += ` 131 - </div> <!-- Close card div --> 132 - </body> 133 - </html> 134 - ` 135 - 136 - w.Write([]byte(html)) 137 } 138 } 139 140 - func handleLinkLastfmForm(database *db.DB) http.HandlerFunc { 141 return func(w http.ResponseWriter, r *http.Request) { 142 - userID, _ := session.GetUserID(r.Context()) 143 if r.Method == http.MethodPost { 144 if err := r.ParseForm(); err != nil { 145 http.Error(w, "Failed to parse form", http.StatusBadRequest) ··· 174 } 175 176 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) 205 } 206 } 207 ··· 287 288 func apiMusicBrainzSearch(mbService *musicbrainz.MusicBrainzService) http.HandlerFunc { 289 return func(w http.ResponseWriter, r *http.Request) { 290 291 params := musicbrainz.SearchParams{ 292 Track: r.URL.Query().Get("track"), ··· 416 jsonResponse(w, http.StatusOK, map[string]string{"message": "Last.fm username unlinked successfully"}) 417 } 418 }
··· 8 "strconv" 9 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" 15 "github.com/teal-fm/piper/service/musicbrainz" 16 + "github.com/teal-fm/piper/service/playingnow" 17 "github.com/teal-fm/piper/service/spotify" 18 "github.com/teal-fm/piper/session" 19 ) 20 21 + type HomeParams struct { 22 + NavBar pages.NavBar 23 + } 24 + 25 + func home(database *db.DB, pg *pages.Pages) http.HandlerFunc { 26 return func(w http.ResponseWriter, r *http.Request) { 27 28 w.Header().Set("Content-Type", "text/html") ··· 40 log.Printf("Error fetching user %d details for home page: %v", userID, err) 41 } 42 } 43 + params := HomeParams{ 44 + NavBar: pages.NavBar{ 45 + IsLoggedIn: isLoggedIn, 46 + LastFMUsername: lastfmUsername, 47 + }, 48 } 49 + err := pg.Execute("home", w, params) 50 + if err != nil { 51 + log.Printf("Error executing template: %v", err) 52 } 53 } 54 } 55 56 + func handleLinkLastfmForm(database *db.DB, pg *pages.Pages) http.HandlerFunc { 57 return func(w http.ResponseWriter, r *http.Request) { 58 + userID, authenticated := session.GetUserID(r.Context()) 59 if r.Method == http.MethodPost { 60 if err := r.ParseForm(); err != nil { 61 http.Error(w, "Failed to parse form", http.StatusBadRequest) ··· 90 } 91 92 w.Header().Set("Content-Type", "text/html") 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 + } 108 } 109 } 110 ··· 190 191 func apiMusicBrainzSearch(mbService *musicbrainz.MusicBrainzService) http.HandlerFunc { 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 + } 197 198 params := musicbrainz.SearchParams{ 199 Track: r.URL.Query().Get("track"), ··· 323 jsonResponse(w, http.StatusOK, map[string]string{"message": "Last.fm username unlinked successfully"}) 324 } 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
···
··· 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
··· 5 "fmt" 6 "log" 7 "net/http" 8 - "os" 9 "time" 10 11 "github.com/spf13/viper" 12 "github.com/teal-fm/piper/config" 13 "github.com/teal-fm/piper/db" 14 "github.com/teal-fm/piper/oauth" 15 "github.com/teal-fm/piper/oauth/atproto" 16 apikeyService "github.com/teal-fm/piper/service/apikey" 17 - "github.com/teal-fm/piper/service/lastfm" 18 "github.com/teal-fm/piper/service/musicbrainz" 19 "github.com/teal-fm/piper/service/spotify" 20 "github.com/teal-fm/piper/session" 21 ) 22 23 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 31 } 32 33 // JSON API handlers ··· 52 log.Fatalf("Error initializing database: %v", err) 53 } 54 55 // --- 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) 60 } 61 - jwks, err := atproto.LoadJwks(jwksBytes) 62 - if err != nil { 63 - log.Fatalf("Error loading JWK: %v", err) 64 } 65 atprotoService, err := atproto.NewATprotoAuthService( 66 database, 67 - jwks, 68 viper.GetString("atproto.client_id"), 69 viper.GetString("atproto.callback_url"), 70 ) 71 if err != nil { 72 log.Fatalf("Error creating ATproto auth service: %v", err) 73 } 74 75 mbService := musicbrainz.NewMusicBrainzService(database) 76 - spotifyService := spotify.NewSpotifyService(database, atprotoService, mbService) 77 - lastfmService := lastfm.NewLastFMService(database, viper.GetString("lastfm.api_key"), mbService, atprotoService) 78 79 - sessionManager := session.NewSessionManager(database) 80 - oauthManager := oauth.NewOAuthServiceManager(sessionManager) 81 82 spotifyOAuth := oauth.NewOAuth2Service( 83 viper.GetString("spotify.client_id"), ··· 93 apiKeyService := apikeyService.NewAPIKeyService(database, sessionManager) 94 95 app := &application{ 96 - database: database, 97 - sessionManager: sessionManager, 98 - oauthManager: oauthManager, 99 - apiKeyService: apiKeyService, 100 - mbService: mbService, 101 - spotifyService: spotifyService, 102 - atprotoService: atprotoService, 103 } 104 105 trackerInterval := time.Duration(viper.GetInt("tracker.interval")) * time.Second ··· 108 lastfmInterval = 30 * time.Second 109 } 110 111 - if err := spotifyService.LoadAllUsers(); err != nil { 112 - log.Printf("Warning: Failed to preload Spotify users: %v", err) 113 - } 114 go spotifyService.StartListeningTracker(trackerInterval) 115 116 go lastfmService.StartListeningTracker(lastfmInterval)
··· 5 "fmt" 6 "log" 7 "net/http" 8 "time" 9 + 10 + "github.com/teal-fm/piper/service/lastfm" 11 + "github.com/teal-fm/piper/service/playingnow" 12 13 "github.com/spf13/viper" 14 "github.com/teal-fm/piper/config" 15 "github.com/teal-fm/piper/db" 16 "github.com/teal-fm/piper/oauth" 17 "github.com/teal-fm/piper/oauth/atproto" 18 + pages "github.com/teal-fm/piper/pages" 19 apikeyService "github.com/teal-fm/piper/service/apikey" 20 "github.com/teal-fm/piper/service/musicbrainz" 21 "github.com/teal-fm/piper/service/spotify" 22 "github.com/teal-fm/piper/session" 23 ) 24 25 type application struct { 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 35 } 36 37 // JSON API handlers ··· 56 log.Fatalf("Error initializing database: %v", err) 57 } 58 59 + sessionManager := session.NewSessionManager(database) 60 + 61 // --- Service Initializations --- 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 67 } 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 72 } 73 + 74 atprotoService, err := atproto.NewATprotoAuthService( 75 database, 76 + sessionManager, 77 + newJwkPrivateKey, 78 viper.GetString("atproto.client_id"), 79 viper.GetString("atproto.callback_url"), 80 + clientSecretKeyId, 81 ) 82 if err != nil { 83 log.Fatalf("Error creating ATproto auth service: %v", err) 84 } 85 86 mbService := musicbrainz.NewMusicBrainzService(database) 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) 90 91 + oauthManager := oauth.NewOAuthServiceManager() 92 93 spotifyOAuth := oauth.NewOAuth2Service( 94 viper.GetString("spotify.client_id"), ··· 104 apiKeyService := apikeyService.NewAPIKeyService(database, sessionManager) 105 106 app := &application{ 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(), 116 } 117 118 trackerInterval := time.Duration(viper.GetInt("tracker.interval")) * time.Second ··· 121 lastfmInterval = 30 * time.Second 122 } 123 124 go spotifyService.StartListeningTracker(trackerInterval) 125 126 go lastfmService.StartListeningTracker(lastfmInterval)
+12 -6
cmd/routes.go
··· 11 func (app *application) routes() http.Handler { 12 mux := http.NewServeMux() 13 14 - mux.HandleFunc("/", session.WithPossibleAuth(home(app.database), app.sessionManager)) 15 16 // OAuth Routes 17 mux.HandleFunc("/login/spotify", app.oauthManager.HandleLogin("spotify")) ··· 22 // Authenticated Web Routes 23 mux.HandleFunc("/current-track", session.WithAuth(app.spotifyService.HandleCurrentTrack, app.sessionManager)) 24 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) 29 mux.HandleFunc("/debug/", session.WithAuth(app.sessionManager.HandleDebug, app.sessionManager)) 30 31 mux.HandleFunc("/api/v1/me", session.WithAPIAuth(apiMeHandler(app.database), app.sessionManager)) ··· 36 mux.HandleFunc("/api/v1/history", session.WithAPIAuth(apiTrackHistory(app.spotifyService), app.sessionManager)) // Spotify History 37 mux.HandleFunc("/api/v1/musicbrainz/search", apiMusicBrainzSearch(app.mbService)) // MusicBrainz (public?) 38 39 serverUrlRoot := viper.GetString("server.root_url") 40 atpClientId := viper.GetString("atproto.client_id") 41 atpCallbackUrl := viper.GetString("atproto.callback_url") 42 - mux.HandleFunc("/.well-known/client-metadata.json", func(w http.ResponseWriter, r *http.Request) { 43 app.atprotoService.HandleClientMetadata(w, r, serverUrlRoot, atpClientId, atpCallbackUrl) 44 }) 45 mux.HandleFunc("/oauth/jwks.json", app.atprotoService.HandleJwks)
··· 11 func (app *application) routes() http.Handler { 12 mux := http.NewServeMux() 13 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)) 18 19 // OAuth Routes 20 mux.HandleFunc("/login/spotify", app.oauthManager.HandleLogin("spotify")) ··· 25 // Authenticated Web Routes 26 mux.HandleFunc("/current-track", session.WithAuth(app.spotifyService.HandleCurrentTrack, app.sessionManager)) 27 mux.HandleFunc("/history", session.WithAuth(app.spotifyService.HandleTrackHistory, app.sessionManager)) 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")) 32 mux.HandleFunc("/debug/", session.WithAuth(app.sessionManager.HandleDebug, app.sessionManager)) 33 34 mux.HandleFunc("/api/v1/me", session.WithAPIAuth(apiMeHandler(app.database), app.sessionManager)) ··· 39 mux.HandleFunc("/api/v1/history", session.WithAPIAuth(apiTrackHistory(app.spotifyService), app.sessionManager)) // Spotify History 40 mux.HandleFunc("/api/v1/musicbrainz/search", apiMusicBrainzSearch(app.mbService)) // MusicBrainz (public?) 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 + 45 serverUrlRoot := viper.GetString("server.root_url") 46 atpClientId := viper.GetString("atproto.client_id") 47 atpCallbackUrl := viper.GetString("atproto.callback_url") 48 + mux.HandleFunc("/oauth-client-metadata.json", func(w http.ResponseWriter, r *http.Request) { 49 app.atprotoService.HandleClientMetadata(w, r, serverUrlRoot, atpClientId, atpCallbackUrl) 50 }) 51 mux.HandleFunc("/oauth/jwks.json", app.atprotoService.HandleJwks)
+18
compose.yml
···
··· 1 + services: 2 + piper: 3 + build: 4 + context: . 5 + dockerfile: Dockerfile 6 + ports: 7 + - "8080:8080" 8 + env_file: 9 + - .env 10 + volumes: 11 + - piper_data:/db 12 + networks: 13 + - app_network 14 + volumes: 15 + piper_data: 16 + networks: 17 + app_network: 18 + driver: bridge
+261 -170
db/atproto.go
··· 3 import ( 4 "context" 5 "database/sql" 6 - "encoding/json" 7 "fmt" 8 "time" 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/models" 14 ) 15 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 func (db *DB) FindOrCreateUserByDID(did string) (*models.User, error) { 75 var user models.User 76 err := db.QueryRow(` ··· 108 return &user, err 109 } 110 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().UTC().Add(time.Second * time.Duration(tokenResp.ExpiresIn)) 115 now := time.Now().UTC() 116 117 - dpopPrivateJWKBytes, err := json.Marshal(dpopPrivateJWK) 118 - if err != nil { 119 - return err 120 - } 121 - 122 result, err := db.Exec(` 123 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 = ?, 135 updated_at = ? 136 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 - "", 149 now, 150 - tokenResp.Sub, 151 ) 152 - 153 if err != nil { 154 - return fmt.Errorf("failed to update atproto session for did %s: %w", tokenResp.Sub, err) 155 } 156 157 rowsAffected, err := result.RowsAffected() 158 if err != nil { 159 // 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) 161 } 162 163 if rowsAffected == 0 { 164 - return fmt.Errorf("no user found with did %s to update session, creating new session", tokenResp.Sub) 165 } 166 167 return nil 168 } 169 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 174 175 - err := db.QueryRow( 176 - ` 177 - SELECT id, 178 - atproto_did, 179 - atproto_pds_url, 180 - atproto_authserver_issuer, 181 - atproto_access_token, 182 - atproto_refresh_token, 183 - atproto_pds_nonce, 184 - atproto_authserver_nonce, 185 - atproto_dpop_private_jwk, 186 - atproto_token_expiry 187 - FROM users 188 - WHERE atproto_did = ?`, 189 - did, 190 - ).Scan( 191 - &oauthSession.ID, 192 - &oauthSession.DID, 193 - &oauthSession.PDSUrl, 194 - &authserverIss, 195 - &oauthSession.AccessToken, 196 - &oauthSession.RefreshToken, 197 - &oauthSession.DpopPdsNonce, 198 - &oauthSession.DpopAuthServerNonce, 199 - &jwkBytes, 200 - &oauthSession.TokenExpiry, 201 ) 202 203 if err != nil { 204 - return nil, fmt.Errorf("failed to get atproto session for did %s: %w", did, err) 205 } 206 207 - privateJwk, err := helpers.ParseJWKFromBytes([]byte(jwkBytes)) 208 if err != nil { 209 - return nil, fmt.Errorf("failed to parse DPoPPrivateJWK: %w", err) 210 - } else { 211 - // add jwk to the struct 212 - oauthSession.DpopPrivateJWK = privateJwk 213 } 214 215 - // printout the session details 216 - fmt.Printf("Getting session details for the did: %+v\n", oauthSession.DID) 217 218 - // if token is expired, refresh it 219 - if time.Now().UTC().After(oauthSession.TokenExpiry) { 220 221 - resp, err := oauthClient.RefreshTokenRequest(ctx, oauthSession.RefreshToken, authserverIss, oauthSession.DpopAuthServerNonce, privateJwk) 222 - if err != nil { 223 - return nil, err 224 - } 225 226 - if err := db.SaveATprotoSession(resp, authserverIss, privateJwk, oauthSession.PDSUrl); err != nil { 227 - return nil, fmt.Errorf("failed to save refreshed token: %w", err) 228 - } 229 230 - oauthSession = models.ATprotoAuthSession{ 231 - ID: oauthSession.ID, 232 - DID: oauthSession.DID, 233 - PDSUrl: oauthSession.PDSUrl, 234 - AuthServerIssuer: authserverIss, 235 - AccessToken: resp.AccessToken, 236 - RefreshToken: resp.RefreshToken, 237 - DpopPdsNonce: oauthSession.DpopPdsNonce, 238 - DpopAuthServerNonce: resp.DpopAuthserverNonce, 239 - DpopPrivateJWK: privateJwk, 240 - TokenExpiry: time.Now().UTC().Add(time.Duration(resp.ExpiresIn) * time.Second), 241 } 242 243 } 244 - 245 - return &oauthSession, nil 246 } 247 248 - func AtpSessionToAuthArgs(sess *models.ATprotoAuthSession) *oauth.XrpcAuthedRequestArgs { 249 - //Commenting out so jwts and tokens are not in logs 250 - //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) 251 - return &oauth.XrpcAuthedRequestArgs{ 252 - Did: sess.DID, 253 - PdsUrl: sess.PDSUrl, 254 - Issuer: sess.AuthServerIssuer, 255 - AccessToken: sess.AccessToken, 256 - DpopPdsNonce: sess.DpopPdsNonce, 257 - DpopPrivateJwk: sess.DpopPrivateJWK, 258 - } 259 }
··· 3 import ( 4 "context" 5 "database/sql" 6 "fmt" 7 + "strings" 8 "time" 9 10 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 "github.com/teal-fm/piper/models" 13 ) 14 15 func (db *DB) FindOrCreateUserByDID(did string) (*models.User, error) { 16 var user models.User 17 err := db.QueryRow(` ··· 49 return &user, err 50 } 51 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() 55 56 result, err := db.Exec(` 57 UPDATE users 58 + SET 59 + most_recent_at_session_id = ?, 60 updated_at = ? 61 WHERE atproto_did = ?`, 62 + atProtoSessionID, 63 now, 64 + did, 65 ) 66 if err != nil { 67 + db.logger.Printf("%v", err) 68 + return fmt.Errorf("failed to update atproto session for did %s: %w", did, atProtoSessionID) 69 } 70 71 rowsAffected, err := result.RowsAffected() 72 if err != nil { 73 // it's possible the update succeeded here? 74 + return fmt.Errorf("failed to check rows affected after updating atproto session for did %s: %w", did, atProtoSessionID) 75 } 76 77 if rowsAffected == 0 { 78 + return fmt.Errorf("no user found with did %s to update session, creating new session", did) 79 } 80 81 return nil 82 } 83 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 + } 106 + 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 131 + ) 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 + } 168 if err != nil { 169 + return nil, err 170 } 171 172 + accDID, err := syntax.ParseDID(accountDIDStr) 173 if err != nil { 174 + return nil, fmt.Errorf("invalid account DID in session: %w", err) 175 } 176 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 + } 191 192 + return &sess, nil 193 + } 194 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 + } 232 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 + } 238 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) 285 } 286 + accountDIDPtr = &acc 287 + } 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 301 + } 302 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 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 350 }
+61 -25
db/db.go
··· 4 "database/sql" 5 "encoding/json" 6 "fmt" 7 "os" 8 "path/filepath" 9 "time" ··· 14 15 type DB struct { 16 *sql.DB 17 } 18 19 func New(dbPath string) (*DB, error) { ··· 31 if err = db.Ping(); err != nil { 32 return nil, err 33 } 34 35 - return &DB{db}, nil 36 } 37 38 func (db *DB) Initialize() error { ··· 42 username TEXT, -- Made nullable, might not have username initially 43 email TEXT UNIQUE, -- Made nullable 44 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, 56 spotify_id TEXT UNIQUE, -- Spotify specific ID 57 access_token TEXT, -- Spotify access token 58 refresh_token TEXT, -- Spotify refresh token ··· 88 } 89 90 _, 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 - )`) 102 if err != nil { 103 return err 104 } ··· 159 user := &models.User{} 160 161 err := db.QueryRow(` 162 - SELECT id, username, email, atproto_did, spotify_id, access_token, refresh_token, token_expiry, lastfm_username, created_at, updated_at 163 FROM users WHERE id = ?`, ID).Scan( 164 - &user.ID, &user.Username, &user.Email, &user.ATProtoDID, &user.SpotifyID, 165 &user.AccessToken, &user.RefreshToken, &user.TokenExpiry, 166 &user.LastFMUsername, 167 &user.CreatedAt, &user.UpdatedAt) ··· 469 470 return &lastTimestamp, nil 471 }
··· 4 "database/sql" 5 "encoding/json" 6 "fmt" 7 + "log" 8 "os" 9 "path/filepath" 10 "time" ··· 15 16 type DB struct { 17 *sql.DB 18 + logger *log.Logger 19 } 20 21 func New(dbPath string) (*DB, error) { ··· 33 if err = db.Ping(); err != nil { 34 return nil, err 35 } 36 + logger := log.New(os.Stdout, "db: ", log.LstdFlags|log.Lmsgprefix) 37 38 + return &DB{db, logger}, nil 39 } 40 41 func (db *DB) Initialize() error { ··· 45 username TEXT, -- Made nullable, might not have username initially 46 email TEXT UNIQUE, -- Made nullable 47 atproto_did TEXT UNIQUE, -- Atproto DID (identifier) 48 + most_recent_at_session_id TEXT, -- Most recent oAuth session id 49 spotify_id TEXT UNIQUE, -- Spotify specific ID 50 access_token TEXT, -- Spotify access token 51 refresh_token TEXT, -- Spotify refresh token ··· 81 } 82 83 _, err = db.Exec(` 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 + `) 125 if err != nil { 126 return err 127 } ··· 182 user := &models.User{} 183 184 err := db.QueryRow(` 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 197 FROM users WHERE id = ?`, ID).Scan( 198 + &user.ID, &user.Username, &user.Email, &user.ATProtoDID, &user.MostRecentAtProtoSessionID, &user.SpotifyID, 199 &user.AccessToken, &user.RefreshToken, &user.TokenExpiry, 200 &user.LastFMUsername, 201 &user.CreatedAt, &user.UpdatedAt) ··· 503 504 return &lastTimestamp, nil 505 } 506 + 507 + //
+2 -2
db/lfm.go
··· 42 43 func (db *DB) GetUserByLastFM(lastfmUsername string) (*models.User, error) { 44 row := db.QueryRow(` 45 - SELECT id, username, email, atproto_did, created_at, updated_at, lastfm_username 46 FROM users 47 WHERE lastfm_username = ?`, lastfmUsername) 48 49 user := &models.User{} 50 err := row.Scan( 51 - &user.ID, &user.Username, &user.Email, &user.ATProtoDID, 52 &user.CreatedAt, &user.UpdatedAt, &user.LastFMUsername) 53 if err != nil { 54 return nil, err
··· 42 43 func (db *DB) GetUserByLastFM(lastfmUsername string) (*models.User, error) { 44 row := db.QueryRow(` 45 + SELECT id, username, email, atproto_did, most_recent_at_session_id, created_at, updated_at, lastfm_username 46 FROM users 47 WHERE lastfm_username = ?`, lastfmUsername) 48 49 user := &models.User{} 50 err := row.Scan( 51 + &user.ID, &user.Username, &user.Email, &user.ATProtoDID, &user.MostRecentAtProtoSessionID, 52 &user.CreatedAt, &user.UpdatedAt, &user.LastFMUsername) 53 if err != nil { 54 return nil, err
+29 -3
go.mod
··· 3 go 1.24.0 4 5 require ( 6 - github.com/bluesky-social/indigo v0.0.0-20250506174012-7075cf22f63e 7 github.com/dlclark/regexp2 v1.11.5 8 - github.com/haileyok/atproto-oauth-golang v0.0.2 9 github.com/ipfs/go-cid v0.4.1 10 github.com/joho/godotenv v1.5.1 11 github.com/justinas/alice v1.2.0 ··· 21 require ( 22 dario.cat/mergo v1.0.1 // indirect 23 github.com/air-verse/air v1.61.7 // indirect 24 github.com/bep/godartsass v1.2.0 // indirect 25 github.com/bep/godartsass/v2 v2.1.0 // indirect 26 github.com/bep/golibsass v1.2.0 // indirect 27 github.com/carlmjohnson/versioninfo v0.22.5 // indirect 28 github.com/cli/safeexec v1.0.1 // indirect 29 github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 30 github.com/creack/pty v1.1.23 // indirect ··· 39 github.com/goccy/go-json v0.10.2 // indirect 40 github.com/gogo/protobuf v1.3.2 // indirect 41 github.com/gohugoio/hugo v0.134.3 // indirect 42 - github.com/golang-jwt/jwt/v5 v5.2.1 // indirect 43 github.com/google/uuid v1.6.0 // indirect 44 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 45 github.com/hashicorp/go-retryablehttp v0.7.5 // indirect 46 github.com/hashicorp/golang-lru v1.0.2 // indirect 47 github.com/ipfs/bbloom v0.0.4 // indirect 48 github.com/ipfs/go-block-format v0.2.0 // indirect 49 github.com/ipfs/go-datastore v0.6.0 // indirect ··· 56 github.com/ipfs/go-log/v2 v2.5.1 // indirect 57 github.com/ipfs/go-metrics-interface v0.0.1 // indirect 58 github.com/jbenet/goprocess v0.1.4 // indirect 59 github.com/klauspost/cpuid/v2 v2.2.7 // indirect 60 github.com/lestrrat-go/blackmagic v1.0.2 // indirect 61 github.com/lestrrat-go/httpcc v1.0.1 // indirect 62 github.com/lestrrat-go/httprc v1.0.4 // indirect ··· 64 github.com/lestrrat-go/option v1.0.1 // indirect 65 github.com/mattn/go-colorable v0.1.13 // indirect 66 github.com/mattn/go-isatty v0.0.20 // indirect 67 github.com/minio/sha256-simd v1.0.1 // indirect 68 github.com/mr-tron/base58 v1.2.0 // indirect 69 github.com/multiformats/go-base32 v0.1.0 // indirect ··· 71 github.com/multiformats/go-multibase v0.2.0 // indirect 72 github.com/multiformats/go-multihash v0.2.3 // indirect 73 github.com/multiformats/go-varint v0.0.7 // indirect 74 github.com/opentracing/opentracing-go v1.2.0 // indirect 75 github.com/pelletier/go-toml v1.9.5 // indirect 76 github.com/pelletier/go-toml/v2 v2.2.3 // indirect 77 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 78 github.com/russross/blackfriday/v2 v2.1.0 // indirect 79 github.com/sagikazarmark/locafero v0.7.0 // indirect 80 github.com/segmentio/asm v1.2.0 // indirect 81 github.com/sourcegraph/conc v0.3.0 // indirect 82 github.com/spaolacci/murmur3 v1.1.0 // indirect ··· 86 github.com/subosito/gotenv v1.6.0 // indirect 87 github.com/tdewolff/parse/v2 v2.7.15 // indirect 88 github.com/urfave/cli/v2 v2.27.5 // indirect 89 github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 90 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect 91 go.opentelemetry.io/otel v1.29.0 // indirect 92 go.opentelemetry.io/otel/metric v1.29.0 // indirect ··· 96 go.uber.org/zap v1.26.0 // indirect 97 golang.org/x/crypto v0.32.0 // indirect 98 golang.org/x/mod v0.21.0 // indirect 99 golang.org/x/sys v0.29.0 // indirect 100 golang.org/x/text v0.21.0 // indirect 101 google.golang.org/protobuf v1.36.1 // indirect 102 gopkg.in/yaml.v3 v3.0.1 // indirect 103 lukechampine.com/blake3 v1.2.1 // indirect 104 ) 105
··· 3 go 1.24.0 4 5 require ( 6 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 7 github.com/dlclark/regexp2 v1.11.5 8 github.com/ipfs/go-cid v0.4.1 9 github.com/joho/godotenv v1.5.1 10 github.com/justinas/alice v1.2.0 ··· 20 require ( 21 dario.cat/mergo v1.0.1 // indirect 22 github.com/air-verse/air v1.61.7 // indirect 23 + github.com/beorn7/perks v1.0.1 // indirect 24 github.com/bep/godartsass v1.2.0 // indirect 25 github.com/bep/godartsass/v2 v2.1.0 // indirect 26 github.com/bep/golibsass v1.2.0 // indirect 27 github.com/carlmjohnson/versioninfo v0.22.5 // indirect 28 + github.com/cespare/xxhash/v2 v2.3.0 // indirect 29 github.com/cli/safeexec v1.0.1 // indirect 30 github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 31 github.com/creack/pty v1.1.23 // indirect ··· 40 github.com/goccy/go-json v0.10.2 // indirect 41 github.com/gogo/protobuf v1.3.2 // indirect 42 github.com/gohugoio/hugo v0.134.3 // indirect 43 + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 44 + github.com/google/go-querystring v1.1.0 // indirect 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 49 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 50 github.com/hashicorp/go-retryablehttp v0.7.5 // indirect 51 github.com/hashicorp/golang-lru v1.0.2 // indirect 52 + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 53 github.com/ipfs/bbloom v0.0.4 // indirect 54 github.com/ipfs/go-block-format v0.2.0 // indirect 55 github.com/ipfs/go-datastore v0.6.0 // indirect ··· 62 github.com/ipfs/go-log/v2 v2.5.1 // indirect 63 github.com/ipfs/go-metrics-interface v0.0.1 // indirect 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 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 71 github.com/lestrrat-go/blackmagic v1.0.2 // indirect 72 github.com/lestrrat-go/httpcc v1.0.1 // indirect 73 github.com/lestrrat-go/httprc v1.0.4 // indirect ··· 75 github.com/lestrrat-go/option v1.0.1 // indirect 76 github.com/mattn/go-colorable v0.1.13 // indirect 77 github.com/mattn/go-isatty v0.0.20 // indirect 78 + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect 79 github.com/minio/sha256-simd v1.0.1 // indirect 80 github.com/mr-tron/base58 v1.2.0 // indirect 81 github.com/multiformats/go-base32 v0.1.0 // indirect ··· 83 github.com/multiformats/go-multibase v0.2.0 // indirect 84 github.com/multiformats/go-multihash v0.2.3 // indirect 85 github.com/multiformats/go-varint v0.0.7 // indirect 86 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 87 github.com/opentracing/opentracing-go v1.2.0 // indirect 88 github.com/pelletier/go-toml v1.9.5 // indirect 89 github.com/pelletier/go-toml/v2 v2.2.3 // indirect 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 95 github.com/russross/blackfriday/v2 v2.1.0 // indirect 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 99 github.com/segmentio/asm v1.2.0 // indirect 100 github.com/sourcegraph/conc v0.3.0 // indirect 101 github.com/spaolacci/murmur3 v1.1.0 // indirect ··· 105 github.com/subosito/gotenv v1.6.0 // indirect 106 github.com/tdewolff/parse/v2 v2.7.15 // indirect 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 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 113 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect 114 go.opentelemetry.io/otel v1.29.0 // indirect 115 go.opentelemetry.io/otel/metric v1.29.0 // indirect ··· 119 go.uber.org/zap v1.26.0 // indirect 120 golang.org/x/crypto v0.32.0 // indirect 121 golang.org/x/mod v0.21.0 // indirect 122 + golang.org/x/net v0.33.0 // indirect 123 golang.org/x/sys v0.29.0 // indirect 124 golang.org/x/text v0.21.0 // indirect 125 google.golang.org/protobuf v1.36.1 // indirect 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 129 lukechampine.com/blake3 v1.2.1 // indirect 130 ) 131
+61 -4
go.sum
··· 11 github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M= 12 github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 13 github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 14 github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps= 15 github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU= 16 github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= ··· 37 github.com/bep/overlayfs v0.9.2/go.mod h1:aYY9W7aXQsGcA7V9x/pzeR8LjEgIxbtisZm8Q7zPz40= 38 github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI= 39 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/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= 43 github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 44 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= ··· 121 github.com/gohugoio/locales v0.14.0/go.mod h1:ip8cCAv/cnmVLzzXtiTpPwgJ4xhKZranqNqtoIu0b/4= 122 github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo= 123 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/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 127 github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 128 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= ··· 138 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 139 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 140 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 141 github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= 142 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 143 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 144 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 145 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 146 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 147 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 148 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 149 github.com/haileyok/atproto-oauth-golang v0.0.2 h1:61KPkLB615LQXR2f5x1v3sf6vPe6dOXqNpTYCgZ0Fz8= 150 github.com/haileyok/atproto-oauth-golang v0.0.2/go.mod h1:jcZ4GCjo5I5RuE/RsAXg1/b6udw7R4W+2rb/cGyTDK8= 151 github.com/hairyhenderson/go-codeowners v0.5.0 h1:dpQB+hVHiRc2VVvc2BHxkuM+tmu9Qej/as3apqUbsWc= ··· 199 github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 200 github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU= 201 github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA= 202 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 203 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 204 github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= ··· 221 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 222 github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U= 223 github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE= 224 github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 225 github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= 226 github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= ··· 251 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 252 github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU= 253 github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 254 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 255 github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 256 github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= ··· 273 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 274 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 275 github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 276 github.com/niklasfasching/go-org v1.7.0 h1:vyMdcMWWTe/XmANk19F4k8XGBYg0GQ/gJGMimOjGMek= 277 github.com/niklasfasching/go-org v1.7.0/go.mod h1:WuVm4d45oePiE0eX25GqTDQIt/qPW1T9DGkRscqLW5o= 278 github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= ··· 297 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 298 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 299 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 300 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 301 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 302 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 303 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= ··· 309 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 310 github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 311 github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 312 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 313 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 314 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= ··· 356 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 357 github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= 358 github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= 359 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= 360 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 361 github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11 h1:5HZfQkwe0mIfyDmc1Em5GqlNRzcdtlv4HTNmdpt7XH0= ··· 372 github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 373 github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4= 374 github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= 375 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= 376 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= 377 go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= ··· 537 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 538 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 539 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 540 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 541 honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 542 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
··· 11 github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M= 12 github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 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= 16 github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps= 17 github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU= 18 github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= ··· 39 github.com/bep/overlayfs v0.9.2/go.mod h1:aYY9W7aXQsGcA7V9x/pzeR8LjEgIxbtisZm8Q7zPz40= 40 github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI= 41 github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0= 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= 44 github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= 45 github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 46 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= ··· 123 github.com/gohugoio/locales v0.14.0/go.mod h1:ip8cCAv/cnmVLzzXtiTpPwgJ4xhKZranqNqtoIu0b/4= 124 github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo= 125 github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0= 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= 128 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 129 github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 130 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= ··· 140 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 141 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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= 144 github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= 145 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 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= 149 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 150 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 151 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 152 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 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= 160 github.com/haileyok/atproto-oauth-golang v0.0.2 h1:61KPkLB615LQXR2f5x1v3sf6vPe6dOXqNpTYCgZ0Fz8= 161 github.com/haileyok/atproto-oauth-golang v0.0.2/go.mod h1:jcZ4GCjo5I5RuE/RsAXg1/b6udw7R4W+2rb/cGyTDK8= 162 github.com/hairyhenderson/go-codeowners v0.5.0 h1:dpQB+hVHiRc2VVvc2BHxkuM+tmu9Qej/as3apqUbsWc= ··· 210 github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 211 github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU= 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= 217 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 218 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 219 github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= ··· 236 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 237 github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U= 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= 245 github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 246 github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= 247 github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= ··· 272 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 273 github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU= 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= 277 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 278 github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 279 github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= ··· 296 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 297 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 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= 301 github.com/niklasfasching/go-org v1.7.0 h1:vyMdcMWWTe/XmANk19F4k8XGBYg0GQ/gJGMimOjGMek= 302 github.com/niklasfasching/go-org v1.7.0/go.mod h1:WuVm4d45oePiE0eX25GqTDQIt/qPW1T9DGkRscqLW5o= 303 github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= ··· 322 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 323 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 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= 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= 342 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 343 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 344 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= ··· 350 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 351 github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 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= 357 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 358 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 359 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= ··· 401 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 402 github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= 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= 408 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= 409 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 410 github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11 h1:5HZfQkwe0mIfyDmc1Em5GqlNRzcdtlv4HTNmdpt7XH0= ··· 421 github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 422 github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4= 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= 428 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= 429 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= 430 go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= ··· 590 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 591 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 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= 597 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 598 honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 599 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+22 -14
lexicons/teal/feed/defs.json
··· 5 "defs": { 6 "playView": { 7 "type": "object", 8 - "required": ["trackName", "artistNames"], 9 "properties": { 10 "trackName": { 11 "type": "string", ··· 26 "type": "integer", 27 "description": "The length of the track in seconds" 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": { 40 "type": "array", 41 "items": { 42 - "type": "string" 43 }, 44 - "description": "Array of Musicbrainz artist IDs" 45 }, 46 "releaseName": { 47 "type": "string", ··· 75 "type": "string", 76 "format": "datetime", 77 "description": "The unix timestamp of when the track was played" 78 } 79 } 80 }
··· 5 "defs": { 6 "playView": { 7 "type": "object", 8 + "required": ["trackName", "artists"], 9 "properties": { 10 "trackName": { 11 "type": "string", ··· 26 "type": "integer", 27 "description": "The length of the track in seconds" 28 }, 29 + "artists": { 30 "type": "array", 31 "items": { 32 + "type": "ref", 33 + "ref": "#artist" 34 }, 35 + "description": "Array of artists in order of original appearance." 36 }, 37 "releaseName": { 38 "type": "string", ··· 66 "type": "string", 67 "format": "datetime", 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" 86 } 87 } 88 }
+11 -3
lexicons/teal/feed/play.json
··· 8 "key": "tid", 9 "record": { 10 "type": "object", 11 - "required": ["trackName", "artistNames"], 12 "properties": { 13 "trackName": { 14 "type": "string", ··· 38 "maxLength": 256, 39 "maxGraphemes": 2560 40 }, 41 - "description": "Array of artist names in order of original appearance." 42 }, 43 "artistMbIds": { 44 "type": "array", 45 "items": { 46 "type": "string" 47 }, 48 - "description": "Array of Musicbrainz artist IDs" 49 }, 50 "releaseName": { 51 "type": "string",
··· 8 "key": "tid", 9 "record": { 10 "type": "object", 11 + "required": ["trackName"], 12 "properties": { 13 "trackName": { 14 "type": "string", ··· 38 "maxLength": 256, 39 "maxGraphemes": 2560 40 }, 41 + "description": "Array of artist names in order of original appearance. Prefer using 'artists'." 42 }, 43 "artistMbIds": { 44 "type": "array", 45 "items": { 46 "type": "string" 47 }, 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." 57 }, 58 "releaseName": { 59 "type": "string",
+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
··· 7 PlayID int64 `json:"playId"` 8 Name string `json:"name"` 9 // analogous to "track" 10 - RecordingMBID string `json:"trackMBID"` 11 Artist []Artist `json:"artist"` 12 Album string `json:"album"` 13 // analogous to "album" 14 - ReleaseMBID string `json:"releaseMBID"` 15 URL string `json:"url"` 16 Timestamp time.Time `json:"timestamp"` 17 DurationMs int64 `json:"durationMs"` ··· 22 } 23 24 type Artist struct { 25 - Name string `json:"name"` 26 - ID string `json:"id"` 27 - MBID string `json:"mbid"` 28 }
··· 7 PlayID int64 `json:"playId"` 8 Name string `json:"name"` 9 // analogous to "track" 10 + RecordingMBID *string `json:"trackMBID,omitempty"` 11 Artist []Artist `json:"artist"` 12 Album string `json:"album"` 13 // analogous to "album" 14 + ReleaseMBID *string `json:"releaseMBID,omitempty"` 15 URL string `json:"url"` 16 Timestamp time.Time `json:"timestamp"` 17 DurationMs int64 `json:"durationMs"` ··· 22 } 23 24 type Artist struct { 25 + Name string `json:"name"` 26 + ID string `json:"id"` 27 + MBID *string `json:"mbid,omitempty"` 28 }
+7 -4
models/user.go
··· 18 LastFMUsername *string 19 20 // atp info 21 - ATProtoDID *string 22 - ATProtoAccessToken *string 23 - ATProtoRefreshToken *string 24 - ATProtoTokenExpiry *time.Time 25 26 CreatedAt time.Time 27 UpdatedAt time.Time
··· 18 LastFMUsername *string 19 20 // atp info 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 28 29 CreatedAt time.Time 30 UpdatedAt time.Time
+104 -134
oauth/atproto/atproto.go
··· 3 import ( 4 "context" 5 "fmt" 6 "log" 7 "net/http" 8 "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" 15 ) 16 17 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 } 25 26 - func NewATprotoAuthService(db *db.DB, jwks jwk.Key, clientId string, callbackUrl string) (*ATprotoAuthService, error) { 27 fmt.Println(clientId, callbackUrl) 28 - cli, err := oauth.NewClient(oauth.ClientArgs{ 29 - ClientJwk: jwks, 30 - ClientId: clientId, 31 - RedirectUri: callbackUrl, 32 - }) 33 if err != nil { 34 - return nil, fmt.Errorf("failed to create atproto oauth client: %w", err) 35 } 36 svc := &ATprotoAuthService{ 37 - client: cli, 38 - jwks: jwks, 39 - callbackUrl: callbackUrl, 40 - DB: db, 41 - clientId: clientId, 42 } 43 - svc.NewXrpcClient() 44 return svc, nil 45 } 46 47 - func (a *ATprotoAuthService) GetATProtoClient() (*oauth.Client, error) { 48 - if a.client != nil { 49 - return a.client, nil 50 } 51 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 62 } 63 64 - return a.client, nil 65 - } 66 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 } 74 75 func (a *ATprotoAuthService) HandleLogin(w http.ResponseWriter, r *http.Request) { 76 handle := r.URL.Query().Get("handle") 77 if handle == "" { 78 - log.Printf("ATProto Login Error: handle is required") 79 http.Error(w, "handle query parameter is required", http.StatusBadRequest) 80 return 81 } 82 - 83 - authUrl, err := a.getLoginUrlAndSaveState(r.Context(), handle) 84 if err != nil { 85 - log.Printf("ATProto Login Error: Failed to get login URL for handle %s: %v", handle, err) 86 http.Error(w, fmt.Sprintf("Error initiating login: %v", err), http.StatusInternalServerError) 87 - return 88 } 89 90 - log.Printf("ATProto Login: Redirecting user %s to %s", handle, authUrl.String()) 91 http.Redirect(w, r, authUrl.String(), http.StatusFound) 92 } 93 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 - } 109 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 - } 115 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 - } 126 127 - // print data 128 - fmt.Println(data) 129 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) 133 } 134 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() 145 146 - return authEndpointURL, nil 147 } 148 149 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 - } 161 162 - // Retrieve saved data using state 163 - data, err := a.DB.GetATprotoAuthData(state) 164 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") 168 } 169 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") 177 } 178 179 - resp, err := a.client.InitialTokenRequest(r.Context(), code, issuer, data.PKCEVerifier, data.DPoPAuthServerNonce, data.DPoPPrivateJWK) 180 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) 189 http.Error(w, "Failed to process user information.", http.StatusInternalServerError) 190 return 0, fmt.Errorf("failed to find or create user") 191 } 192 193 - err = a.DB.SaveATprotoSession(resp, data.AuthServerIssuer, data.DPoPPrivateJWK, data.PDSUrl) 194 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) 196 } 197 198 - log.Printf("ATProto Callback Success: User %d (DID: %s) authenticated.", userID.ID, data.DID) 199 - return userID.ID, nil 200 }
··· 3 import ( 4 "context" 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 + 16 "log" 17 "net/http" 18 "net/url" 19 + "os" 20 + "slices" 21 ) 22 23 type ATprotoAuthService struct { 24 + clientApp *oauth.ClientApp 25 + DB *db.DB 26 + sessionManager *session.SessionManager 27 + clientId string 28 + callbackUrl string 29 + logger *log.Logger 30 } 31 32 + func NewATprotoAuthService(database *db.DB, sessionManager *session.SessionManager, clientSecretKey string, clientId string, callbackUrl string, clientSecretId string) (*ATprotoAuthService, error) { 33 fmt.Println(clientId, callbackUrl) 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) 41 if err != nil { 42 + return nil, err 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 + 52 svc := &ATprotoAuthService{ 53 + clientApp: oauthClient, 54 + callbackUrl: callbackUrl, 55 + DB: database, 56 + sessionManager: sessionManager, 57 + clientId: clientId, 58 + logger: logger, 59 } 60 return svc, nil 61 } 62 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 67 } 68 69 + oauthSess, err := a.clientApp.ResumeSession(ctx, did, sessionID) 70 + if err != nil { 71 + return nil, err 72 } 73 74 + return oauthSess.APIClient(), nil 75 76 } 77 78 func (a *ATprotoAuthService) HandleLogin(w http.ResponseWriter, r *http.Request) { 79 handle := r.URL.Query().Get("handle") 80 if handle == "" { 81 + a.logger.Printf("ATProto Login Error: handle is required") 82 http.Error(w, "handle query parameter is required", http.StatusBadRequest) 83 return 84 } 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) 91 if err != nil { 92 http.Error(w, fmt.Sprintf("Error initiating login: %v", err), http.StatusInternalServerError) 93 } 94 95 + a.logger.Printf("ATProto Login: Redirecting user %s to %s", handle, authUrl.String()) 96 http.Redirect(w, r, authUrl.String(), http.StatusFound) 97 } 98 99 + func (a *ATprotoAuthService) HandleLogout(w http.ResponseWriter, r *http.Request) { 100 + cookie, err := r.Cookie("session") 101 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 + } 108 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) 115 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 + } 121 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) 128 } 129 130 + a.sessionManager.ClearSessionCookie(w) 131 132 + http.Redirect(w, r, "/", http.StatusSeeOther) 133 } 134 135 func (a *ATprotoAuthService) HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error) { 136 + ctx := r.Context() 137 138 + sessData, err := a.clientApp.ProcessCallback(ctx, r.URL.Query()) 139 if err != nil { 140 + errMsg := fmt.Errorf("processing OAuth callback: %w", err) 141 + http.Error(w, errMsg.Error(), http.StatusBadRequest) 142 + return 0, errMsg 143 } 144 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") 149 } 150 151 + user, err := a.DB.FindOrCreateUserByDID(sessData.AccountDID.String()) 152 if err != nil { 153 + a.logger.Printf("ATProto Callback Error: Failed to find or create user for DID %s: %v", sessData.AccountDID.String(), err) 154 http.Error(w, "Failed to process user information.", http.StatusInternalServerError) 155 return 0, fmt.Errorf("failed to find or create user") 156 } 157 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) 164 if err != nil { 165 + a.logger.Printf("Failed to set latest atproto session id for user %d: %v", user.ID, err) 166 } 167 168 + a.logger.Printf("ATProto Callback Success: User %d (DID: %s) authenticated.", user.ID, user.ATProtoDID) 169 + return user.ID, nil 170 }
+26 -30
oauth/atproto/http.go
··· 4 import ( 5 "encoding/json" 6 "fmt" 7 - "log" 8 "net/http" 9 - 10 - "github.com/haileyok/atproto-oauth-golang/helpers" 11 ) 12 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 - } 20 21 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) 24 } 25 } 26 27 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", 44 } 45 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) 48 } 49 }
··· 4 import ( 5 "encoding/json" 6 "fmt" 7 "net/http" 8 ) 9 10 + func strPtr(raw string) *string { 11 + return &raw 12 + } 13 14 + func (a *ATprotoAuthService) HandleJwks(w http.ResponseWriter, r *http.Request) { 15 w.Header().Set("Content-Type", "application/json") 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 20 } 21 } 22 23 func (a *ATprotoAuthService) HandleClientMetadata(w http.ResponseWriter, r *http.Request, serverUrlRoot, serverMetadataUrl, serverCallbackUrl string) { 24 + 25 + meta := a.clientApp.Config.ClientMetadata() 26 + if a.clientApp.Config.IsConfidential() { 27 + meta.JWKSURI = strPtr(fmt.Sprintf("%s/oauth/jwks.json", serverUrlRoot)) 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 + 39 w.Header().Set("Content-Type", "application/json") 40 + if err := json.NewEncoder(w).Encode(meta); err != nil { 41 + http.Error(w, err.Error(), http.StatusInternalServerError) 42 + return 43 } 44 + 45 }
+2 -55
oauth/atproto/resolve.go
··· 12 "strings" 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 - oauth "github.com/haileyok/atproto-oauth-golang" 16 ) 17 18 // user information struct 19 type UserInformation struct { 20 - AuthService string `json:"authService"` 21 - AuthServer string `json:"authServer"` 22 - AuthMeta *oauth.OauthAuthorizationMetadata `json:"authMeta"` 23 // do NOT save the current handle permanently! 24 Handle string `json:"handle"` 25 DID string `json:"did"` ··· 32 Type string `json:"type"` 33 ServiceEndpoint string `json:"serviceEndpoint"` 34 } `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 } 87 88 func resolveHandle(ctx context.Context, handle string) (string, error) {
··· 12 "strings" 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 ) 16 17 // user information struct 18 type UserInformation struct { 19 + AuthService string `json:"authService"` 20 + AuthServer string `json:"authServer"` 21 // do NOT save the current handle permanently! 22 Handle string `json:"handle"` 23 DID string `json:"did"` ··· 30 Type string `json:"type"` 31 ServiceEndpoint string `json:"serviceEndpoint"` 32 } `json:"service"` 33 } 34 35 func resolveHandle(ctx context.Context, handle string) (string, error) {
-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 - }
···
+5
oauth/oauth2.go
··· 86 http.Redirect(w, r, authURL, http.StatusSeeOther) 87 } 88 89 func (o *OAuth2Service) HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error) { 90 state := r.URL.Query().Get("state") 91 if state != o.state {
··· 86 http.Redirect(w, r, authURL, http.StatusSeeOther) 87 } 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 + 94 func (o *OAuth2Service) HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error) { 95 state := r.URL.Query().Get("state") 96 if state != o.state {
+28 -19
oauth/oauth_manager.go
··· 6 "log" 7 "net/http" 8 "sync" 9 - 10 - "github.com/teal-fm/piper/session" 11 ) 12 13 // manages multiple oauth client services 14 type OAuthServiceManager struct { 15 - services map[string]AuthService 16 - sessionManager *session.SessionManager 17 - mu sync.RWMutex 18 } 19 20 - func NewOAuthServiceManager(sessionManager *session.SessionManager) *OAuthServiceManager { 21 return &OAuthServiceManager{ 22 - services: make(map[string]AuthService), 23 - sessionManager: sessionManager, 24 } 25 } 26 ··· 29 m.mu.Lock() 30 defer m.mu.Unlock() 31 m.services[name] = service 32 - log.Printf("Registered auth service: %s", name) 33 } 34 35 // get an AuthService by registered name ··· 51 return 52 } 53 54 - log.Printf("Auth service '%s' not found for login request", serviceName) 55 http.Error(w, fmt.Sprintf("Auth service '%s' not found", serviceName), http.StatusNotFound) 56 } 57 } ··· 62 service, exists := m.services[serviceName] 63 m.mu.RUnlock() 64 65 - log.Printf("Logging in with service %s", serviceName) 66 67 if !exists { 68 - log.Printf("Auth service '%s' not found for callback request", serviceName) 69 http.Error(w, fmt.Sprintf("OAuth service '%s' not found", serviceName), http.StatusNotFound) 70 return 71 } ··· 73 userID, err := service.HandleCallback(w, r) 74 75 if err != nil { 76 - log.Printf("Error handling callback for service '%s': %v", serviceName, err) 77 http.Error(w, fmt.Sprintf("Error handling callback for service '%s'", serviceName), http.StatusInternalServerError) 78 return 79 } 80 81 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 88 http.Redirect(w, r, "/", http.StatusSeeOther) 89 } else { 90 - log.Printf("Callback for service '%s' did not result in a valid user ID.", serviceName) 91 // todo: redirect to an error page 92 // right now this just redirects home but we don't want this behaviour ideally 93 http.Redirect(w, r, "/", http.StatusSeeOther)
··· 6 "log" 7 "net/http" 8 "sync" 9 ) 10 11 // manages multiple oauth client services 12 type OAuthServiceManager struct { 13 + services map[string]AuthService 14 + mu sync.RWMutex 15 + logger *log.Logger 16 } 17 18 + func NewOAuthServiceManager() *OAuthServiceManager { 19 return &OAuthServiceManager{ 20 + services: make(map[string]AuthService), 21 + logger: log.New(log.Writer(), "oauth: ", log.LstdFlags|log.Lmsgprefix), 22 } 23 } 24 ··· 27 m.mu.Lock() 28 defer m.mu.Unlock() 29 m.services[name] = service 30 + m.logger.Printf("Registered auth service: %s", name) 31 } 32 33 // get an AuthService by registered name ··· 49 return 50 } 51 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) 69 http.Error(w, fmt.Sprintf("Auth service '%s' not found", serviceName), http.StatusNotFound) 70 } 71 } ··· 76 service, exists := m.services[serviceName] 77 m.mu.RUnlock() 78 79 + m.logger.Printf("Logging in with service %s", serviceName) 80 81 if !exists { 82 + m.logger.Printf("Auth service '%s' not found for callback request", serviceName) 83 http.Error(w, fmt.Sprintf("OAuth service '%s' not found", serviceName), http.StatusNotFound) 84 return 85 } ··· 87 userID, err := service.HandleCallback(w, r) 88 89 if err != nil { 90 + m.logger.Printf("Error handling callback for service '%s': %v", serviceName, err) 91 http.Error(w, fmt.Sprintf("Error handling callback for service '%s'", serviceName), http.StatusInternalServerError) 92 return 93 } 94 95 if userID > 0 { 96 97 http.Redirect(w, r, "/", http.StatusSeeOther) 98 } else { 99 + m.logger.Printf("Callback for service '%s' did not result in a valid user ID.", serviceName) 100 // todo: redirect to an error page 101 // right now this just redirects home but we don't want this behaviour ideally 102 http.Redirect(w, r, "/", http.StatusSeeOther)
+2
oauth/service.go
··· 10 // handles the callback for the provider. is responsible for inserting 11 // sessions in the db 12 HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error) 13 } 14 15 // optional but recommended
··· 10 // handles the callback for the provider. is responsible for inserting 11 // sessions in the db 12 HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error) 13 + 14 + HandleLogout(w http.ResponseWriter, r *http.Request) 15 } 16 17 // optional but recommended
+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
···
··· 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 + @import "tailwindcss";
+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
···
··· 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 }}
+20
pages/templates/components/navBar.gohtml
···
··· 1 + {{ define "components/navBar" }} 2 + 3 + <nav class="flex flex-wrap mb-5 gap-x-4 gap-y-1"> 4 + <a class="text-[#1DB954] font-bold no-underline" href="/">Home</a> 5 + 6 + {{if .IsLoggedIn}} 7 + <a class="text-[#1DB954] font-bold no-underline" href="/current-track">Spotify Current</a> 8 + <a class="text-[#1DB954] font-bold no-underline" href="/history">Spotify History</a> 9 + <a class="text-[#1DB954] font-bold no-underline" href="/link-lastfm">Link Last.fm</a> 10 + {{ if .LastFMUsername }} 11 + <a class="text-[#1DB954] font-bold no-underline" href="/lastfm/recent">Last.fm Recent</a> 12 + {{ end }} 13 + <a class="text-[#1DB954] font-bold no-underline" href="/api-keys">API Keys</a> 14 + <a class="text-[#1DB954] font-bold no-underline" href="/login/spotify">Connect Spotify Account</a> 15 + <a class="text-[#1DB954] font-bold no-underline" href="/logout">Logout</a> 16 + {{ else }} 17 + <a class="text-[#1DB954] font-bold no-underline" href="/login/atproto">Login with ATProto</a> 18 + {{ end }} 19 + </nav> 20 + {{ end }}
+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
···
··· 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
···
··· 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
··· 3 import ( 4 "encoding/json" 5 "fmt" 6 - "html/template" 7 "log" 8 "net/http" 9 "time" 10 11 "github.com/teal-fm/piper/db" 12 db_apikey "github.com/teal-fm/piper/db/apikey" // Assuming this is the package for ApiKey struct 13 "github.com/teal-fm/piper/session" 14 ) 15 ··· 41 jsonResponse(w, statusCode, map[string]string{"error": message}) 42 } 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) 53 } 54 - return 55 - } 56 57 - isAPI := session.IsAPIRequest(r.Context()) 58 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}) 71 72 - case http.MethodPost: 73 - var reqBody struct { 74 - Name string `json:"name"` 75 } 76 - if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { 77 - jsonError(w, "Invalid request body: "+err.Error(), http.StatusBadRequest) 78 return 79 } 80 - keyName := reqBody.Name 81 if keyName == "" { 82 - keyName = fmt.Sprintf("API Key (via API) - %s", time.Now().UTC().Format(time.RFC3339)) 83 } 84 - validityDays := 30 // Default, could be made configurable via request body 85 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) 90 if err != nil { 91 - jsonError(w, fmt.Sprintf("Error creating API key: %v", err), http.StatusInternalServerError) 92 return 93 } 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 - }) 101 102 - case http.MethodDelete: 103 keyID := r.URL.Query().Get("key_id") 104 if keyID == "" { 105 - jsonError(w, "Query parameter 'key_id' is required", http.StatusBadRequest) 106 return 107 } 108 109 key, exists := s.sessions.GetAPIKeyManager().GetApiKey(keyID) 110 if !exists || key.UserID != userID { 111 - jsonError(w, "API key not found or not owned by user", http.StatusNotFound) 112 return 113 } 114 ··· 116 jsonError(w, fmt.Sprintf("Error deleting API key: %v", err), http.StatusInternalServerError) 117 return 118 } 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) 131 return 132 } 133 134 - keyName := r.FormValue("name") 135 - if keyName == "" { 136 - keyName = fmt.Sprintf("API Key - %s", time.Now().UTC().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) 145 if err != nil { 146 - http.Error(w, fmt.Sprintf("Error creating API key: %v", err), http.StatusInternalServerError) 147 return 148 } 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 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 - } 162 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 167 } 168 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 172 } 173 - // AJAX client expects JSON 174 - jsonResponse(w, http.StatusOK, map[string]any{"success": true}) 175 - return 176 - } 177 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 - }, 368 } 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 }
··· 3 import ( 4 "encoding/json" 5 "fmt" 6 "log" 7 "net/http" 8 "time" 9 10 "github.com/teal-fm/piper/db" 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 "github.com/teal-fm/piper/session" 14 ) 15 ··· 41 jsonResponse(w, statusCode, map[string]string{"error": message}) 42 } 43 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 57 } 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 + } 117 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 + } 123 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"}) 129 130 + default: 131 + jsonError(w, "Method not allowed", http.StatusMethodNotAllowed) 132 } 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) 140 return 141 } 142 + 143 + keyName := r.FormValue("name") 144 if keyName == "" { 145 + keyName = fmt.Sprintf("API Key - %s", time.Now().UTC().Format(time.RFC3339)) 146 } 147 + validityDays := 1024 148 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) 154 if err != nil { 155 + http.Error(w, fmt.Sprintf("Error creating API key: %v", err), http.StatusInternalServerError) 156 return 157 } 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 + } 163 164 + if r.Method == http.MethodDelete { // Delete key via AJAX from HTML page 165 keyID := r.URL.Query().Get("key_id") 166 if keyID == "" { 167 + // For AJAX, a JSON error response is more appropriate than http.Error 168 + jsonError(w, "Key ID is required", http.StatusBadRequest) 169 return 170 } 171 172 key, exists := s.sessions.GetAPIKeyManager().GetApiKey(keyID) 173 if !exists || key.UserID != userID { 174 + jsonError(w, "Invalid API key or not owned by user", http.StatusBadRequest) // StatusNotFound or StatusForbidden 175 return 176 } 177 ··· 179 jsonError(w, fmt.Sprintf("Error deleting API key: %v", err), http.StatusInternalServerError) 180 return 181 } 182 + // AJAX client expects JSON 183 + jsonResponse(w, http.StatusOK, map[string]any{"success": true}) 184 return 185 } 186 187 + // GET request: Display HTML page for API Key Management 188 + keys, err := s.sessions.GetAPIKeyManager().GetUserApiKeys(userID) 189 if err != nil { 190 + http.Error(w, fmt.Sprintf("Error fetching API keys: %v", err), http.StatusInternalServerError) 191 return 192 } 193 194 + // newlyCreatedKey will be the ID from the redirect after form POST 195 + newlyCreatedKeyID := r.URL.Query().Get("created") 196 + var newKeyValueToShow string 197 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 204 } 205 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 + }, 218 } 219 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 + } 225 } 226 }
+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 + }
+99 -110
service/lastfm/lastfm.go
··· 3 import ( 4 "context" 5 "encoding/json" 6 - "errors" 7 "fmt" 8 "io" 9 "log" 10 "net/http" 11 "net/url" 12 "strconv" 13 "sync" 14 "time" 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 "github.com/teal-fm/piper/db" 22 "github.com/teal-fm/piper/models" 23 atprotoauth "github.com/teal-fm/piper/oauth/atproto" 24 "github.com/teal-fm/piper/service/musicbrainz" 25 "golang.org/x/time/rate" 26 ) ··· 38 Usernames []string 39 musicBrainzService *musicbrainz.MusicBrainzService 40 atprotoService *atprotoauth.ATprotoAuthService 41 lastSeenNowPlaying map[string]Track 42 mu sync.Mutex 43 } 44 45 - func NewLastFMService(db *db.DB, apiKey string, musicBrainzService *musicbrainz.MusicBrainzService, atprotoService *atprotoauth.ATprotoAuthService) *LastFMService { 46 return &LastFMService{ 47 db: db, 48 httpClient: &http.Client{ ··· 54 Usernames: make([]string, 0), 55 atprotoService: atprotoService, 56 musicBrainzService: musicBrainzService, 57 lastSeenNowPlaying: make(map[string]Track), 58 mu: sync.Mutex{}, 59 } 60 } 61 62 func (l *LastFMService) loadUsernames() error { 63 u, err := l.db.GetAllUsersWithLastFM() 64 if err != nil { 65 - log.Printf("Error loading users with Last.fm from DB: %v", err) 66 return fmt.Errorf("failed to load users from database: %w", err) 67 } 68 usernames := make([]string, len(u)) ··· 71 if user.LastFMUsername != nil { // Check if the username is set 72 usernames[i] = *user.LastFMUsername 73 } else { 74 - log.Printf("User ID %d has Last.fm enabled but no username set", user.ID) 75 } 76 } 77 ··· 84 } 85 86 l.Usernames = filteredUsernames 87 - log.Printf("Loaded %d Last.fm usernames", len(l.Usernames)) 88 89 return nil 90 } ··· 113 return nil, fmt.Errorf("failed to create request for %s: %w", username, err) 114 } 115 116 - log.Printf("Fetching recent tracks for user: %s", username) 117 resp, err := l.httpClient.Do(req) 118 if err != nil { 119 return nil, fmt.Errorf("failed to fetch recent tracks for %s: %w", username, err) ··· 134 } 135 if err := json.Unmarshal(bodyBytes, &recentTracksResp); err != nil { 136 // Log the body content that failed to decode 137 - log.Printf("Failed to decode response body for %s: %s", username, string(bodyBytes)) 138 return nil, fmt.Errorf("failed to decode response for %s: %w", username, err) 139 } 140 141 if len(recentTracksResp.RecentTracks.Tracks) > 0 { 142 - log.Printf("Fetched %d tracks for %s. Most recent: %s - %s", 143 len(recentTracksResp.RecentTracks.Tracks), 144 username, 145 recentTracksResp.RecentTracks.Tracks[0].Artist.Text, 146 recentTracksResp.RecentTracks.Tracks[0].Name) 147 } else { 148 - log.Printf("No recent tracks found for %s", username) 149 } 150 151 return &recentTracksResp, nil ··· 153 154 func (l *LastFMService) StartListeningTracker(interval time.Duration) { 155 if err := l.loadUsernames(); err != nil { 156 - log.Printf("Failed to perform initial username load: %v", err) 157 // Decide if we should proceed without initial load or return error 158 } 159 160 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.") 162 } else { 163 - log.Printf("Found %d Last.fm users.", len(l.Usernames)) 164 } 165 166 ticker := time.NewTicker(interval) ··· 169 if len(l.Usernames) > 0 { 170 l.fetchAllUserTracks(context.Background()) 171 } else { 172 - log.Println("Skipping initial fetch cycle as no users are configured.") 173 } 174 175 for { ··· 177 case <-ticker.C: 178 // refresh usernames periodically from db 179 if err := l.loadUsernames(); err != nil { 180 - log.Printf("Error reloading usernames in ticker: %v", err) 181 // Continue ticker loop even if reload fails? Or log and potentially stop? 182 continue // Continue for now 183 } 184 if len(l.Usernames) > 0 { 185 l.fetchAllUserTracks(context.Background()) 186 } else { 187 - log.Println("No Last.fm users configured. Skipping fetch cycle.") 188 } 189 // TODO: Implement graceful shutdown using context cancellation 190 // case <-ctx.Done(): 191 - // log.Println("Stopping Last.fm listening tracker.") 192 // ticker.Stop() 193 // return 194 } 195 } 196 }() 197 198 - log.Printf("Last.fm Listening Tracker started with interval %v", interval) 199 } 200 201 // fetchAllUserTracks iterates through users and fetches their tracks. 202 func (l *LastFMService) fetchAllUserTracks(ctx context.Context) { 203 - log.Printf("Starting fetch cycle for %d users...", len(l.Usernames)) 204 var wg sync.WaitGroup // Use WaitGroup to fetch concurrently (optional) 205 fetchErrors := make(chan error, len(l.Usernames)) // Channel for errors 206 207 for _, username := range l.Usernames { 208 if ctx.Err() != nil { 209 - log.Printf("Context cancelled before starting fetch for user %s.", username) 210 break // Exit loop if context is cancelled 211 } 212 ··· 214 go func(uname string) { // Launch fetch and process in a goroutine per user 215 defer wg.Done() 216 if ctx.Err() != nil { 217 - log.Printf("Context cancelled during fetch cycle for user %s.", uname) 218 return // Exit goroutine if context is cancelled 219 } 220 ··· 223 const fetchLimit = 5 224 recentTracks, err := l.getRecentTracks(ctx, uname, fetchLimit) 225 if err != nil { 226 - log.Printf("Error fetching tracks for %s: %v", uname, err) 227 fetchErrors <- fmt.Errorf("fetch failed for %s: %w", uname, err) // Report error 228 return 229 } 230 231 if recentTracks == nil || len(recentTracks.RecentTracks.Tracks) == 0 { 232 - log.Printf("No tracks returned for user %s", uname) 233 return 234 } 235 236 // Process the fetched tracks 237 if err := l.processTracks(ctx, uname, recentTracks.RecentTracks.Tracks); err != nil { 238 - log.Printf("Error processing tracks for %s: %v", uname, err) 239 fetchErrors <- fmt.Errorf("process failed for %s: %w", uname, err) // Report error 240 } 241 }(username) ··· 247 // Log any errors that occurred during the fetch cycle 248 errorCount := 0 249 for err := range fetchErrors { 250 - log.Printf("Fetch cycle error: %v", err) 251 errorCount++ 252 } 253 254 if errorCount > 0 { 255 - log.Printf("Finished fetch cycle with %d errors.", errorCount) 256 } else { 257 - log.Println("Finished fetch cycle successfully.") 258 } 259 } 260 ··· 274 } 275 276 if lastKnownTimestamp == nil { 277 - log.Printf("no previous scrobble timestamp found for user %s. processing latest track.", username) 278 } else { 279 - log.Printf("last known scrobble for %s was at %s", username, lastKnownTimestamp.Format(time.RFC3339)) 280 } 281 282 var ( ··· 287 // handle now playing track separately 288 if len(tracks) > 0 && tracks[0].Attr != nil && tracks[0].Attr.NowPlaying == "true" { 289 nowPlayingTrack := tracks[0] 290 - log.Printf("now playing track for %s: %s - %s", username, nowPlayingTrack.Artist.Text, nowPlayingTrack.Name) 291 l.mu.Lock() 292 lastSeen, existed := l.lastSeenNowPlaying[username] 293 // if our current track matches with last seen 294 // just compare artist/album/name for now 295 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) 297 } else { 298 - log.Printf("current track does not match last seen track for %s", username) 299 // aha! we record this! 300 l.lastSeenNowPlaying[username] = nowPlayingTrack 301 } 302 l.mu.Unlock() 303 } 304 305 // find last non-now-playing track ··· 312 } 313 314 if lastNonNowPlaying == nil { 315 - log.Printf("no non-now-playing tracks found for user %s.", username) 316 return nil 317 } 318 319 latestTrackTime := lastNonNowPlaying.Date 320 321 // print both 322 - fmt.Printf("latestTrackTime: %s\n", latestTrackTime) 323 - fmt.Printf("lastKnownTimestamp: %s\n", lastKnownTimestamp) 324 325 if lastKnownTimestamp != nil && lastKnownTimestamp.Equal(latestTrackTime.Time) { 326 - log.Printf("no new tracks to process for user %s.", username) 327 return nil 328 } 329 330 for _, track := range tracks { 331 if track.Date == nil { 332 - log.Printf("skipping track without timestamp for %s: %s - %s", username, track.Artist.Text, track.Name) 333 continue 334 } 335 ··· 337 // before or at last known 338 if lastKnownTimestamp != nil && (trackTime.Before(*lastKnownTimestamp) || trackTime.Equal(*lastKnownTimestamp)) { 339 if processedCount == 0 { 340 - log.Printf("reached already known scrobbles for user %s (track time: %s, last known: %s).", 341 username, trackTime.Format(time.RFC3339), lastKnownTimestamp.Format(time.RFC3339)) 342 } 343 break ··· 360 361 hydratedTrack, err := musicbrainz.HydrateTrack(l.musicBrainzService, baseTrack) 362 if err != nil { 363 - log.Printf("error hydrating track for user %s: %s - %s: %v", username, track.Artist.Text, track.Name, err) 364 // we can use the track without MBIDs, it's still valid 365 hydratedTrack = &baseTrack 366 } 367 l.db.SaveTrack(user.ID, hydratedTrack) 368 - log.Printf("Submitting track") 369 - err = l.SubmitTrackToPDS(*user.ATProtoDID, hydratedTrack, ctx) 370 if err != nil { 371 - log.Printf("error submitting track for user %s: %s - %s: %v", username, track.Artist.Text, track.Name, err) 372 } 373 processedCount++ 374 ··· 382 } 383 384 if processedCount > 0 { 385 - log.Printf("processed %d new track(s) for user %s. latest timestamp: %s", 386 processedCount, username, latestProcessedTime.Format(time.RFC3339)) 387 } 388 389 return nil 390 } 391 392 - func (l *LastFMService) SubmitTrackToPDS(did string, track *models.Track, ctx context.Context) error { 393 - client, err := l.atprotoService.GetATProtoClient() 394 - if err != nil || client == nil { 395 - return err 396 - } 397 - 398 - xrpcClient := l.atprotoService.GetXrpcClient() 399 - if xrpcClient == nil { 400 - return errors.New("xrpc client is kil") 401 - } 402 - 403 - // we check for client above 404 - sess, err := l.db.GetAtprotoSession(did, ctx, *client) 405 - if err != nil { 406 - return fmt.Errorf("Couldn't get Atproto session: %s", err) 407 - } 408 - 409 - // printout the session details 410 - fmt.Printf("Submitting track for the did: %+v\n", sess.DID) 411 - 412 - // horrible no good very bad for now 413 - artistArr := []string{} 414 - artistMbIdArr := []string{} 415 - for _, a := range track.Artist { 416 - artistArr = append(artistArr, a.Name) 417 - artistMbIdArr = append(artistMbIdArr, a.MBID) 418 - } 419 420 - var durationPtr *int64 421 - if track.DurationMs > 0 { 422 - durationSeconds := track.DurationMs / 1000 423 - durationPtr = &durationSeconds 424 } 425 426 - playedTimeStr := track.Timestamp.Format(time.RFC3339) 427 - submissionAgent := viper.GetString("app.submission_agent") 428 - if submissionAgent == "" { 429 - submissionAgent = "piper/v0.0.1" // Default if not configured 430 - } 431 432 - // track -> tealfm track 433 - tfmTrack := teal.AlphaFeedPlay{ 434 - LexiconTypeID: "fm.teal.alpha.feed.play", // Assuming this is the correct Lexicon ID 435 - // tfm specifies duration in seconds 436 - Duration: durationPtr, // Pointer required 437 - TrackName: track.Name, 438 - // should be unix timestamp 439 - PlayedTime: &playedTimeStr, // Pointer required 440 - ArtistNames: artistArr, // Slice of strings is correct 441 - ArtistMbIds: artistMbIdArr, // Slice of strings is correct 442 - ReleaseMbId: &track.ReleaseMBID, // Pointer required 443 - ReleaseName: &track.Album, // Pointer required 444 - RecordingMbId: &track.RecordingMBID, // Pointer required 445 - SubmissionClientAgent: &submissionAgent, // Pointer required 446 } 447 448 - input := atproto.RepoCreateRecord_Input{ 449 - Collection: "fm.teal.alpha.feed.play", 450 - Repo: sess.DID, 451 - Record: &lexutil.LexiconTypeDecoder{Val: &tfmTrack}, 452 } 453 454 - authArgs := db.AtpSessionToAuthArgs(sess) 455 - fmt.Println(authArgs) 456 - 457 - var out atproto.RepoCreateRecord_Output 458 - if err := xrpcClient.Do(ctx, authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &out); err != nil { 459 - return err 460 } 461 462 - // submit track to PDS 463 - 464 - return nil 465 }
··· 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "io" 8 "log" 9 "net/http" 10 "net/url" 11 + "os" 12 "strconv" 13 "sync" 14 "time" 15 16 "github.com/teal-fm/piper/db" 17 "github.com/teal-fm/piper/models" 18 atprotoauth "github.com/teal-fm/piper/oauth/atproto" 19 + atprotoservice "github.com/teal-fm/piper/service/atproto" 20 "github.com/teal-fm/piper/service/musicbrainz" 21 "golang.org/x/time/rate" 22 ) ··· 34 Usernames []string 35 musicBrainzService *musicbrainz.MusicBrainzService 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 lastSeenNowPlaying map[string]Track 42 mu sync.Mutex 43 + logger *log.Logger 44 } 45 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 + 52 return &LastFMService{ 53 db: db, 54 httpClient: &http.Client{ ··· 60 Usernames: make([]string, 0), 61 atprotoService: atprotoService, 62 musicBrainzService: musicBrainzService, 63 + playingNowService: playingNowService, 64 lastSeenNowPlaying: make(map[string]Track), 65 mu: sync.Mutex{}, 66 + logger: logger, 67 } 68 } 69 70 func (l *LastFMService) loadUsernames() error { 71 u, err := l.db.GetAllUsersWithLastFM() 72 if err != nil { 73 + l.logger.Printf("Error loading users with Last.fm from DB: %v", err) 74 return fmt.Errorf("failed to load users from database: %w", err) 75 } 76 usernames := make([]string, len(u)) ··· 79 if user.LastFMUsername != nil { // Check if the username is set 80 usernames[i] = *user.LastFMUsername 81 } else { 82 + l.logger.Printf("User ID %d has Last.fm enabled but no username set", user.ID) 83 } 84 } 85 ··· 92 } 93 94 l.Usernames = filteredUsernames 95 + l.logger.Printf("Loaded %d Last.fm usernames", len(l.Usernames)) 96 97 return nil 98 } ··· 121 return nil, fmt.Errorf("failed to create request for %s: %w", username, err) 122 } 123 124 + l.logger.Printf("Fetching recent tracks for user: %s", username) 125 resp, err := l.httpClient.Do(req) 126 if err != nil { 127 return nil, fmt.Errorf("failed to fetch recent tracks for %s: %w", username, err) ··· 142 } 143 if err := json.Unmarshal(bodyBytes, &recentTracksResp); err != nil { 144 // Log the body content that failed to decode 145 + l.logger.Printf("Failed to decode response body for %s: %s", username, string(bodyBytes)) 146 return nil, fmt.Errorf("failed to decode response for %s: %w", username, err) 147 } 148 149 if len(recentTracksResp.RecentTracks.Tracks) > 0 { 150 + l.logger.Printf("Fetched %d tracks for %s. Most recent: %s - %s", 151 len(recentTracksResp.RecentTracks.Tracks), 152 username, 153 recentTracksResp.RecentTracks.Tracks[0].Artist.Text, 154 recentTracksResp.RecentTracks.Tracks[0].Name) 155 } else { 156 + l.logger.Printf("No recent tracks found for %s", username) 157 } 158 159 return &recentTracksResp, nil ··· 161 162 func (l *LastFMService) StartListeningTracker(interval time.Duration) { 163 if err := l.loadUsernames(); err != nil { 164 + l.logger.Printf("Failed to perform initial username load: %v", err) 165 // Decide if we should proceed without initial load or return error 166 } 167 168 if len(l.Usernames) == 0 { 169 + l.logger.Println("No Last.fm users configured. Tracker will run but fetch cycles will be skipped until users are added.") 170 } else { 171 + l.logger.Printf("Found %d Last.fm users.", len(l.Usernames)) 172 } 173 174 ticker := time.NewTicker(interval) ··· 177 if len(l.Usernames) > 0 { 178 l.fetchAllUserTracks(context.Background()) 179 } else { 180 + l.logger.Println("Skipping initial fetch cycle as no users are configured.") 181 } 182 183 for { ··· 185 case <-ticker.C: 186 // refresh usernames periodically from db 187 if err := l.loadUsernames(); err != nil { 188 + l.logger.Printf("Error reloading usernames in ticker: %v", err) 189 // Continue ticker loop even if reload fails? Or log and potentially stop? 190 continue // Continue for now 191 } 192 if len(l.Usernames) > 0 { 193 l.fetchAllUserTracks(context.Background()) 194 } else { 195 + l.logger.Println("No Last.fm users configured. Skipping fetch cycle.") 196 } 197 // TODO: Implement graceful shutdown using context cancellation 198 // case <-ctx.Done(): 199 + // l.logger.Println("Stopping Last.fm listening tracker.") 200 // ticker.Stop() 201 // return 202 } 203 } 204 }() 205 206 + l.logger.Printf("Last.fm Listening Tracker started with interval %v", interval) 207 } 208 209 // fetchAllUserTracks iterates through users and fetches their tracks. 210 func (l *LastFMService) fetchAllUserTracks(ctx context.Context) { 211 + l.logger.Printf("Starting fetch cycle for %d users...", len(l.Usernames)) 212 var wg sync.WaitGroup // Use WaitGroup to fetch concurrently (optional) 213 fetchErrors := make(chan error, len(l.Usernames)) // Channel for errors 214 215 for _, username := range l.Usernames { 216 if ctx.Err() != nil { 217 + l.logger.Printf("Context cancelled before starting fetch for user %s.", username) 218 break // Exit loop if context is cancelled 219 } 220 ··· 222 go func(uname string) { // Launch fetch and process in a goroutine per user 223 defer wg.Done() 224 if ctx.Err() != nil { 225 + l.logger.Printf("Context cancelled during fetch cycle for user %s.", uname) 226 return // Exit goroutine if context is cancelled 227 } 228 ··· 231 const fetchLimit = 5 232 recentTracks, err := l.getRecentTracks(ctx, uname, fetchLimit) 233 if err != nil { 234 + l.logger.Printf("Error fetching tracks for %s: %v", uname, err) 235 fetchErrors <- fmt.Errorf("fetch failed for %s: %w", uname, err) // Report error 236 return 237 } 238 239 if recentTracks == nil || len(recentTracks.RecentTracks.Tracks) == 0 { 240 + l.logger.Printf("No tracks returned for user %s", uname) 241 return 242 } 243 244 // Process the fetched tracks 245 if err := l.processTracks(ctx, uname, recentTracks.RecentTracks.Tracks); err != nil { 246 + l.logger.Printf("Error processing tracks for %s: %v", uname, err) 247 fetchErrors <- fmt.Errorf("process failed for %s: %w", uname, err) // Report error 248 } 249 }(username) ··· 255 // Log any errors that occurred during the fetch cycle 256 errorCount := 0 257 for err := range fetchErrors { 258 + l.logger.Printf("Fetch cycle error: %v", err) 259 errorCount++ 260 } 261 262 if errorCount > 0 { 263 + l.logger.Printf("Finished fetch cycle with %d errors.", errorCount) 264 } else { 265 + l.logger.Println("Finished fetch cycle successfully.") 266 } 267 } 268 ··· 282 } 283 284 if lastKnownTimestamp == nil { 285 + l.logger.Printf("no previous scrobble timestamp found for user %s. processing latest track.", username) 286 } else { 287 + l.logger.Printf("last known scrobble for %s was at %s", username, lastKnownTimestamp.Format(time.RFC3339)) 288 } 289 290 var ( ··· 295 // handle now playing track separately 296 if len(tracks) > 0 && tracks[0].Attr != nil && tracks[0].Attr.NowPlaying == "true" { 297 nowPlayingTrack := tracks[0] 298 + l.logger.Printf("now playing track for %s: %s - %s", username, nowPlayingTrack.Artist.Text, nowPlayingTrack.Name) 299 l.mu.Lock() 300 lastSeen, existed := l.lastSeenNowPlaying[username] 301 // if our current track matches with last seen 302 // just compare artist/album/name for now 303 if existed && lastSeen.Album == nowPlayingTrack.Album && lastSeen.Name == nowPlayingTrack.Name && lastSeen.Artist == nowPlayingTrack.Artist { 304 + l.logger.Printf("current track matches last seen track for %s", username) 305 } else { 306 + l.logger.Printf("current track does not match last seen track for %s", username) 307 // aha! we record this! 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 + } 318 } 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 + } 327 } 328 329 // find last non-now-playing track ··· 336 } 337 338 if lastNonNowPlaying == nil { 339 + l.logger.Printf("no non-now-playing tracks found for user %s.", username) 340 return nil 341 } 342 343 latestTrackTime := lastNonNowPlaying.Date 344 345 // print both 346 + l.logger.Printf("latestTrackTime: %s\n", latestTrackTime) 347 + l.logger.Printf("lastKnownTimestamp: %s\n", lastKnownTimestamp) 348 349 if lastKnownTimestamp != nil && lastKnownTimestamp.Equal(latestTrackTime.Time) { 350 + l.logger.Printf("no new tracks to process for user %s.", username) 351 return nil 352 } 353 354 for _, track := range tracks { 355 if track.Date == nil { 356 + l.logger.Printf("skipping track without timestamp for %s: %s - %s", username, track.Artist.Text, track.Name) 357 continue 358 } 359 ··· 361 // before or at last known 362 if lastKnownTimestamp != nil && (trackTime.Before(*lastKnownTimestamp) || trackTime.Equal(*lastKnownTimestamp)) { 363 if processedCount == 0 { 364 + l.logger.Printf("reached already known scrobbles for user %s (track time: %s, last known: %s).", 365 username, trackTime.Format(time.RFC3339), lastKnownTimestamp.Format(time.RFC3339)) 366 } 367 break ··· 384 385 hydratedTrack, err := musicbrainz.HydrateTrack(l.musicBrainzService, baseTrack) 386 if err != nil { 387 + l.logger.Printf("error hydrating track for user %s: %s - %s: %v", username, track.Artist.Text, track.Name, err) 388 // we can use the track without MBIDs, it's still valid 389 hydratedTrack = &baseTrack 390 } 391 l.db.SaveTrack(user.ID, hydratedTrack) 392 + l.logger.Printf("Submitting track") 393 + err = l.SubmitTrackToPDS(*user.ATProtoDID, *user.MostRecentAtProtoSessionID, hydratedTrack, ctx) 394 if err != nil { 395 + l.logger.Printf("error submitting track for user %s: %s - %s: %v", username, track.Artist.Text, track.Name, err) 396 } 397 processedCount++ 398 ··· 406 } 407 408 if processedCount > 0 { 409 + l.logger.Printf("processed %d new track(s) for user %s. latest timestamp: %s", 410 processedCount, username, latestProcessedTime.Format(time.RFC3339)) 411 } 412 413 return nil 414 } 415 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 + } 420 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 + }, 429 } 430 431 + // Set timestamp to current time for now playing 432 + timestamp := time.Now() 433 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 441 } 442 443 + // Add URL if available 444 + if track.URL != "" { 445 + piperTrack.URL = track.URL 446 } 447 448 + // Try to extract MBID if available (Last.fm sometimes provides this) 449 + if track.MBID != "" { // MBID is capitalized 450 + piperTrack.RecordingMBID = &track.MBID 451 } 452 453 + return piperTrack 454 }
+14 -11
service/musicbrainz/musicbrainz.go
··· 8 "log" 9 "net/http" 10 "net/url" 11 "sort" 12 "strings" 13 "sync" // Added for mutex ··· 75 cacheMutex sync.RWMutex // Mutex to protect the cache 76 cacheTTL time.Duration // Time-to-live for cache entries 77 cleaner MetadataCleaner // Cleaner for cleaning up expired cache entries 78 } 79 80 // NewMusicBrainzService creates a new service instance with rate limiting and caching. ··· 83 limiter := rate.NewLimiter(rate.Every(time.Second), 1) 84 // Set a default cache TTL (e.g., 1 hour) 85 defaultCacheTTL := 1 * time.Hour 86 - 87 return &MusicBrainzService{ 88 db: db, 89 httpClient: &http.Client{ ··· 94 cacheTTL: defaultCacheTTL, // Set the cache TTL 95 cleaner: *NewMetadataCleaner("Latin"), // Initialize the cleaner 96 // cacheMutex is zero-value ready 97 } 98 } 99 ··· 127 s.cacheMutex.RUnlock() 128 129 if found && now.Before(entry.expiresAt) { 130 - log.Printf("Cache hit for MusicBrainz search: key=%s", cacheKey) 131 // Return the cached data directly. Consider if a deep copy is needed if callers modify results. 132 return entry.recordings, nil 133 } 134 // --- Cache Miss or Expired --- 135 if found { 136 - log.Printf("Cache expired for MusicBrainz search: key=%s", cacheKey) 137 } else { 138 - log.Printf("Cache miss for MusicBrainz search: key=%s", cacheKey) 139 } 140 141 // --- Proceed with API call --- ··· 191 expiresAt: time.Now().UTC().Add(s.cacheTTL), 192 } 193 s.cacheMutex.Unlock() 194 - log.Printf("Cached MusicBrainz search result for key=%s, TTL=%s", cacheKey, s.cacheTTL) 195 196 // Return the newly fetched results 197 return result.Recordings, nil 198 } 199 200 // GetBestRelease selects the 'best' release from a list based on specific criteria. 201 - func GetBestRelease(releases []MusicBrainzRelease, trackTitle string) *MusicBrainzRelease { 202 if len(releases) == 0 { 203 return nil 204 } ··· 251 } 252 253 // 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) 255 r := releases[0] 256 return &r 257 } ··· 279 } 280 281 firstResult := res[0] 282 - firstResultAlbum := GetBestRelease(firstResult.Releases, firstResult.Title) 283 284 // woof. we Might not have any ISRCs! 285 var bestISRC string ··· 293 artists[i] = models.Artist{ 294 Name: a.Name, 295 ID: a.Artist.ID, 296 - MBID: a.Artist.ID, 297 } 298 } 299 ··· 303 Name: track.Name, 304 URL: track.URL, 305 ServiceBaseUrl: track.ServiceBaseUrl, 306 - RecordingMBID: firstResult.ID, 307 Album: firstResultAlbum.Title, 308 - ReleaseMBID: firstResultAlbum.ID, 309 ISRC: bestISRC, 310 Timestamp: track.Timestamp, 311 ProgressMs: track.ProgressMs,
··· 8 "log" 9 "net/http" 10 "net/url" 11 + "os" 12 "sort" 13 "strings" 14 "sync" // Added for mutex ··· 76 cacheMutex sync.RWMutex // Mutex to protect the cache 77 cacheTTL time.Duration // Time-to-live for cache entries 78 cleaner MetadataCleaner // Cleaner for cleaning up expired cache entries 79 + logger *log.Logger // Logger for logging 80 } 81 82 // NewMusicBrainzService creates a new service instance with rate limiting and caching. ··· 85 limiter := rate.NewLimiter(rate.Every(time.Second), 1) 86 // Set a default cache TTL (e.g., 1 hour) 87 defaultCacheTTL := 1 * time.Hour 88 + logger := log.New(os.Stdout, "musicbrainz: ", log.LstdFlags|log.Lmsgprefix) 89 return &MusicBrainzService{ 90 db: db, 91 httpClient: &http.Client{ ··· 96 cacheTTL: defaultCacheTTL, // Set the cache TTL 97 cleaner: *NewMetadataCleaner("Latin"), // Initialize the cleaner 98 // cacheMutex is zero-value ready 99 + logger: logger, 100 } 101 } 102 ··· 130 s.cacheMutex.RUnlock() 131 132 if found && now.Before(entry.expiresAt) { 133 + s.logger.Printf("Cache hit for MusicBrainz search: key=%s", cacheKey) 134 // Return the cached data directly. Consider if a deep copy is needed if callers modify results. 135 return entry.recordings, nil 136 } 137 // --- Cache Miss or Expired --- 138 if found { 139 + s.logger.Printf("Cache expired for MusicBrainz search: key=%s", cacheKey) 140 } else { 141 + s.logger.Printf("Cache miss for MusicBrainz search: key=%s", cacheKey) 142 } 143 144 // --- Proceed with API call --- ··· 194 expiresAt: time.Now().UTC().Add(s.cacheTTL), 195 } 196 s.cacheMutex.Unlock() 197 + s.logger.Printf("Cached MusicBrainz search result for key=%s, TTL=%s", cacheKey, s.cacheTTL) 198 199 // Return the newly fetched results 200 return result.Recordings, nil 201 } 202 203 // GetBestRelease selects the 'best' release from a list based on specific criteria. 204 + func (s *MusicBrainzService) GetBestRelease(releases []MusicBrainzRelease, trackTitle string) *MusicBrainzRelease { 205 if len(releases) == 0 { 206 return nil 207 } ··· 254 } 255 256 // 3. If none found, return the oldest release overall (which is the first one after sorting) 257 + s.logger.Printf("Could not find a suitable release for '%s', picking oldest: '%s' (%s)", trackTitle, releases[0].Title, releases[0].ID) 258 r := releases[0] 259 return &r 260 } ··· 282 } 283 284 firstResult := res[0] 285 + firstResultAlbum := mb.GetBestRelease(firstResult.Releases, firstResult.Title) 286 287 // woof. we Might not have any ISRCs! 288 var bestISRC string ··· 296 artists[i] = models.Artist{ 297 Name: a.Name, 298 ID: a.Artist.ID, 299 + MBID: &a.Artist.ID, 300 } 301 } 302 ··· 306 Name: track.Name, 307 URL: track.URL, 308 ServiceBaseUrl: track.ServiceBaseUrl, 309 + RecordingMBID: &firstResult.ID, 310 Album: firstResultAlbum.Title, 311 + ReleaseMBID: &firstResultAlbum.ID, 312 ISRC: bestISRC, 313 Timestamp: track.Timestamp, 314 ProgressMs: track.ProgressMs,
+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
···
··· 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 + }
+243 -217
service/spotify/spotify.go
··· 9 "log" 10 "net/http" 11 "net/url" 12 "strings" 13 "sync" 14 "time" 15 16 "context" // Added for context.Context 17 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 23 "github.com/teal-fm/piper/db" 24 "github.com/teal-fm/piper/models" 25 atprotoauth "github.com/teal-fm/piper/oauth/atproto" 26 "github.com/teal-fm/piper/service/musicbrainz" 27 "github.com/teal-fm/piper/session" 28 ) 29 30 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 37 } 38 39 - func NewSpotifyService(database *db.DB, atprotoService *atprotoauth.ATprotoAuthService, musicBrainzService *musicbrainz.MusicBrainzService) *SpotifyService { 40 return &SpotifyService{ 41 - DB: database, 42 - atprotoService: atprotoService, 43 - mb: musicBrainzService, 44 - userTracks: make(map[int64]*models.Track), 45 - userTokens: make(map[int64]string), 46 } 47 } 48 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) 71 - } 72 - 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 118 } 119 120 func (s *SpotifyService) SetAccessToken(token string, refreshToken string, userId int64, hasSession bool) (int64, error) { 121 userID, err := s.identifyAndStoreUser(token, refreshToken, userId, hasSession) 122 if err != nil { 123 - log.Printf("Error identifying and storing user: %v", err) 124 return 0, err 125 } 126 return userID, nil ··· 129 func (s *SpotifyService) identifyAndStoreUser(token string, refreshToken string, userId int64, hasSession bool) (int64, error) { 130 userProfile, err := s.fetchSpotifyProfile(token) 131 if err != nil { 132 - log.Printf("Error fetching Spotify profile: %v", err) 133 return 0, err 134 } 135 136 - fmt.Printf("uid: %d hasSession: %t", userId, hasSession) 137 138 user, err := s.DB.GetUserBySpotifyID(userProfile.ID) 139 if err != nil { 140 // 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) 142 return 0, err 143 } 144 ··· 147 // We don't intend users to log in via spotify! 148 if user == nil { 149 if !hasSession { 150 - log.Printf("User does not seem to exist") 151 return 0, fmt.Errorf("user does not seem to exist") 152 } else { 153 // overwrite prev user 154 user, err = s.DB.AddSpotifySession(userId, userProfile.DisplayName, userProfile.Email, userProfile.ID, token, refreshToken, tokenExpiryTime) 155 if err != nil { 156 - log.Printf("Error adding Spotify session for user ID %d: %v", userId, err) 157 return 0, err 158 } 159 } ··· 161 err = s.DB.UpdateUserToken(user.ID, token, refreshToken, tokenExpiryTime) 162 if err != nil { 163 // for now log and continue 164 - log.Printf("Error updating user token for user ID %d: %v", user.ID, err) 165 } else { 166 - log.Printf("Updated token for existing user: %s (ID: %d)", *user.Username, user.ID) 167 } 168 } 169 user.AccessToken = &token ··· 173 s.userTokens[user.ID] = token 174 s.mu.Unlock() 175 176 - log.Printf("User authenticated via Spotify: %s (ID: %d)", *user.Username, user.ID) 177 return user.ID, nil 178 } 179 ··· 199 s.userTokens[user.ID] = *user.AccessToken 200 count++ 201 } else { 202 - token, err := s.refreshTokenInner(user.ID) 203 if err != nil { 204 //Probably should remove the access token and refresh in long run? 205 - log.Printf("Error refreshing token for user %d: %v", user.ID, err) 206 continue 207 } 208 - s.userTokens[user.ID] = token 209 } 210 } 211 - 212 - log.Printf("Loaded %d active users with valid tokens", count) 213 return nil 214 } 215 ··· 279 // Also clear the bad refresh token from the DB 280 updateErr := s.DB.UpdateUserToken(userID, "", "", time.Now().UTC()) // Clear tokens 281 if updateErr != nil { 282 - log.Printf("Failed to clear bad refresh token for user %d: %v", userID, updateErr) 283 } 284 return "", fmt.Errorf("spotify token refresh failed (%d): %s", resp.StatusCode, string(body)) 285 } ··· 305 // Update DB 306 if err := s.DB.UpdateUserToken(userID, tokenResponse.AccessToken, newRefreshToken, newExpiry); err != nil { 307 // Log error but continue, as we have the token in memory 308 - log.Printf("Error updating user token in DB for user %d after refresh: %v", userID, err) 309 } 310 311 // Update in-memory cache ··· 313 s.userTokens[userID] = tokenResponse.AccessToken 314 s.mu.Unlock() 315 316 - log.Printf("Successfully refreshed token for user %d", userID) 317 return tokenResponse.AccessToken, nil 318 } 319 ··· 328 func (s *SpotifyService) RefreshExpiredTokens() { 329 users, err := s.DB.GetUsersWithExpiredTokens() 330 if err != nil { 331 - log.Printf("Error fetching users with expired tokens: %v", err) 332 return 333 } 334 ··· 343 344 if err != nil { 345 // just print out errors here for now 346 - log.Printf("Error from service/spotify/spotify.go when refreshing tokens: %s", err.Error()) 347 } 348 349 refreshed++ 350 } 351 352 if refreshed > 0 { 353 - log.Printf("Refreshed tokens for %d users", refreshed) 354 } 355 } 356 ··· 411 tracks, err := s.DB.GetRecentTracks(userID, 20) 412 if err != nil { 413 http.Error(w, "Error retrieving track history", http.StatusInternalServerError) 414 - log.Printf("Error retrieving track history: %v", err) 415 return 416 } 417 ··· 453 454 // oops, token expired or other client error 455 if resp.StatusCode == 401 && attempt == 0 { // Only refresh on 401 on the first attempt 456 - log.Printf("Spotify token potentially expired for user %d, attempting refresh...", userID) 457 newAccessToken, refreshErr := s.refreshTokenInner(userID) 458 if refreshErr != nil { 459 - log.Printf("Token refresh failed for user %d: %v", userID, refreshErr) 460 // No point retrying if refresh failed 461 return nil, fmt.Errorf("spotify token expired or invalid for user %d and refresh failed: %w", userID, refreshErr) 462 } 463 - log.Printf("Token refreshed for user %d, retrying request...", userID) 464 token = newAccessToken // Update token for the next attempt 465 req.Header.Set("Authorization", "Bearer "+token) // Update header for retry 466 continue // Go to next attempt in the loop ··· 514 } `json:"external_urls"` 515 DurationMs int `json:"duration_ms"` 516 } `json:"item"` 517 - ProgressMS int `json:"progress_ms"` 518 } 519 520 err = json.Unmarshal(bodyBytes, &response) // Use bodyBytes here 521 if err != nil { 522 return nil, fmt.Errorf("failed to unmarshal spotify response: %w", err) 523 } 524 - 525 var artists []models.Artist 526 for _, artist := range response.Item.Artists { 527 artists = append(artists, models.Artist{ ··· 547 return track, nil 548 } 549 550 - func (s *SpotifyService) StartListeningTracker(interval time.Duration) { 551 - ticker := time.NewTicker(interval) 552 - defer ticker.Stop() 553 554 - for range ticker.C { 555 - err := s.LoadAllUsers() 556 if err != nil { 557 - log.Printf("Error loading spotify users: %v", err) 558 continue 559 } 560 - // copy userIDs to avoid holding the lock too long 561 - s.mu.RLock() 562 - userIDs := make([]int64, 0, len(s.userTokens)) 563 - for userID := range s.userTokens { 564 - userIDs = append(userIDs, userID) 565 - } 566 - s.mu.RUnlock() 567 - 568 - for _, userID := range userIDs { 569 - track, err := s.FetchCurrentTrack(userID) 570 - if err != nil { 571 - log.Printf("Error fetching track for user %d: %v", userID, err) 572 - continue 573 - } 574 575 - if track == nil { 576 - continue 577 } 578 579 - s.mu.RLock() 580 - currentTrack := s.userTracks[userID] 581 - s.mu.RUnlock() 582 583 - if currentTrack == nil { 584 - currentTracks, _ := s.DB.GetRecentTracks(userID, 1) 585 - if len(currentTracks) > 0 { 586 - currentTrack = currentTracks[0] 587 - } 588 } 589 590 - // if flagged true, we have a new track 591 - isNewTrack := currentTrack == nil || 592 - currentTrack.Name != track.Name || 593 - // just check the first one for now 594 - currentTrack.Artist[0].Name != track.Artist[0].Name 595 596 - // we stamp a track iff we've played more than half (or 30 seconds whichever is greater) 597 - isStamped := track.ProgressMs > track.DurationMs/2 && track.ProgressMs > 30000 598 599 - // if currentTrack.Timestamp minus track.Timestamp is greater than 30 seconds 600 - isLastTrackStamped := currentTrack != nil && time.Since(currentTrack.Timestamp) > 30*time.Second && 601 - currentTrack.DurationMs > 30000 602 603 - // just log when we stamp tracks 604 - if isNewTrack && isLastTrackStamped && !currentTrack.HasStamped { 605 - log.Printf("User %d stamped (previous) track: %s by %s", userID, currentTrack.Name, currentTrack.Artist) 606 - currentTrack.HasStamped = true 607 - if currentTrack.PlayID != 0 { 608 - s.DB.UpdateTrack(currentTrack.PlayID, currentTrack) 609 610 - log.Printf("Updated!") 611 - } 612 } 613 614 - if isStamped && !currentTrack.HasStamped { 615 - log.Printf("User %d stamped track: %s by %s", userID, track.Name, track.Artist) 616 - track.HasStamped = true 617 - // if currenttrack has a playid and the last track is the same as the current track 618 - if !isNewTrack && currentTrack.PlayID != 0 { 619 - s.DB.UpdateTrack(currentTrack.PlayID, track) 620 621 - // Update in memory 622 - s.mu.Lock() 623 - s.userTracks[userID] = track 624 - s.mu.Unlock() 625 626 - log.Printf("Updated!") 627 } 628 } 629 630 - if isNewTrack { 631 - id, err := s.DB.SaveTrack(userID, track) 632 - if err != nil { 633 - log.Printf("Error saving track for user %d: %v", userID, err) 634 - continue 635 - } 636 637 - track.PlayID = id 638 639 - s.mu.Lock() 640 - s.userTracks[userID] = track 641 - s.mu.Unlock() 642 643 - // Submit to ATProto PDS 644 - // The 'track' variable is *models.Track and has been saved to DB, PlayID is populated. 645 - dbUser, errUser := s.DB.GetUserByID(userID) // Fetch user by their internal ID 646 - if errUser != nil { 647 - log.Printf("User %d: Error fetching user details for PDS submission: %v", userID, errUser) 648 - } else if dbUser == nil { 649 - log.Printf("User %d: User not found in DB. Skipping PDS submission.", userID) 650 - } else if dbUser.ATProtoDID == nil || *dbUser.ATProtoDID == "" { 651 - log.Printf("User %d (%d): ATProto DID not set. Skipping PDS submission for track '%s'.", userID, dbUser.ATProtoDID, track.Name) 652 - } else { 653 - // User has a DID, proceed with hydration and submission 654 - var trackToSubmitToPDS *models.Track = track // Default to the original track (already *models.Track) 655 - if s.mb != nil { // Check if MusicBrainz service is available 656 - // musicbrainz.HydrateTrack expects models.Track as second argument, so we pass *track 657 - // and it returns *models.Track 658 - hydratedTrack, errHydrate := musicbrainz.HydrateTrack(s.mb, *track) 659 - if errHydrate != nil { 660 - 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) 661 - } else { 662 - log.Printf("User %d (%d): Successfully hydrated track '%s' with MusicBrainz.", userID, dbUser.ATProtoDID, track.Name) 663 - trackToSubmitToPDS = hydratedTrack // hydratedTrack is *models.Track 664 - } 665 } else { 666 - log.Printf("User %d (%d): MusicBrainz service not configured. Proceeding with original track data for PDS.", userID, dbUser.ATProtoDID) 667 } 668 669 - artistName := "Unknown Artist" 670 - if len(trackToSubmitToPDS.Artist) > 0 { 671 - artistName = trackToSubmitToPDS.Artist[0].Name 672 - } 673 674 - log.Printf("User %d (%d): Attempting to submit track '%s' by %s to PDS (DID: %s)", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name, artistName, *dbUser.ATProtoDID) 675 - // Use context.Background() for now, or pass down a context if available 676 - if errPDS := s.SubmitTrackToPDS(*dbUser.ATProtoDID, trackToSubmitToPDS, context.Background()); errPDS != nil { 677 - log.Printf("User %d (%d): Error submitting track '%s' to PDS: %v", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name, errPDS) 678 - } else { 679 - log.Printf("User %d (%d): Successfully submitted track '%s' to PDS.", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name) 680 - } 681 } 682 - // End of PDS submission block 683 684 - log.Printf("User %d is listening to: %s by %s", userID, track.Name, track.Artist) 685 } 686 } 687 688 //unloading users to save memory and make sure we get new signups 689 - err = s.LoadAllUsers() 690 if err != nil { 691 log.Printf("Error loading spotify users: %v", err) 692 } 693 - } 694 }
··· 9 "log" 10 "net/http" 11 "net/url" 12 + "os" 13 "strings" 14 "sync" 15 "time" 16 17 "context" // Added for context.Context 18 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 "github.com/teal-fm/piper/db" 24 "github.com/teal-fm/piper/models" 25 atprotoauth "github.com/teal-fm/piper/oauth/atproto" 26 + atprotoservice "github.com/teal-fm/piper/service/atproto" 27 "github.com/teal-fm/piper/service/musicbrainz" 28 "github.com/teal-fm/piper/session" 29 ) 30 31 type SpotifyService struct { 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 43 } 44 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 + 51 return &SpotifyService{ 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, 59 } 60 } 61 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 67 } 68 69 + // Use shared atproto service for submission 70 + return atprotoservice.SubmitPlayToPDS(ctx, did, mostRecentAtProtoSessionID, track, s.atprotoService) 71 } 72 73 func (s *SpotifyService) SetAccessToken(token string, refreshToken string, userId int64, hasSession bool) (int64, error) { 74 userID, err := s.identifyAndStoreUser(token, refreshToken, userId, hasSession) 75 if err != nil { 76 + s.logger.Printf("Error identifying and storing user: %v", err) 77 return 0, err 78 } 79 return userID, nil ··· 82 func (s *SpotifyService) identifyAndStoreUser(token string, refreshToken string, userId int64, hasSession bool) (int64, error) { 83 userProfile, err := s.fetchSpotifyProfile(token) 84 if err != nil { 85 + s.logger.Printf("Error fetching Spotify profile: %v", err) 86 return 0, err 87 } 88 89 + s.logger.Printf("uid: %d hasSession: %t", userId, hasSession) 90 91 user, err := s.DB.GetUserBySpotifyID(userProfile.ID) 92 if err != nil { 93 // This error might mean DB connection issue, not just user not found. 94 + s.logger.Printf("Error checking for user by Spotify ID %s: %v", userProfile.ID, err) 95 return 0, err 96 } 97 ··· 100 // We don't intend users to log in via spotify! 101 if user == nil { 102 if !hasSession { 103 + s.logger.Printf("User does not seem to exist") 104 return 0, fmt.Errorf("user does not seem to exist") 105 } else { 106 // overwrite prev user 107 user, err = s.DB.AddSpotifySession(userId, userProfile.DisplayName, userProfile.Email, userProfile.ID, token, refreshToken, tokenExpiryTime) 108 if err != nil { 109 + s.logger.Printf("Error adding Spotify session for user ID %d: %v", userId, err) 110 return 0, err 111 } 112 } ··· 114 err = s.DB.UpdateUserToken(user.ID, token, refreshToken, tokenExpiryTime) 115 if err != nil { 116 // for now log and continue 117 + s.logger.Printf("Error updating user token for user ID %d: %v", user.ID, err) 118 } else { 119 + s.logger.Printf("Updated token for existing user: %s (ID: %d)", *user.Username, user.ID) 120 } 121 } 122 user.AccessToken = &token ··· 126 s.userTokens[user.ID] = token 127 s.mu.Unlock() 128 129 + s.logger.Printf("User authenticated via Spotify: %s (ID: %d)", *user.Username, user.ID) 130 return user.ID, nil 131 } 132 ··· 152 s.userTokens[user.ID] = *user.AccessToken 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() 167 } 168 } 169 + s.logger.Printf("Loaded %d active users with valid tokens", count) 170 return nil 171 } 172 ··· 236 // Also clear the bad refresh token from the DB 237 updateErr := s.DB.UpdateUserToken(userID, "", "", time.Now().UTC()) // Clear tokens 238 if updateErr != nil { 239 + s.logger.Printf("Failed to clear bad refresh token for user %d: %v", userID, updateErr) 240 } 241 return "", fmt.Errorf("spotify token refresh failed (%d): %s", resp.StatusCode, string(body)) 242 } ··· 262 // Update DB 263 if err := s.DB.UpdateUserToken(userID, tokenResponse.AccessToken, newRefreshToken, newExpiry); err != nil { 264 // Log error but continue, as we have the token in memory 265 + s.logger.Printf("Error updating user token in DB for user %d after refresh: %v", userID, err) 266 } 267 268 // Update in-memory cache ··· 270 s.userTokens[userID] = tokenResponse.AccessToken 271 s.mu.Unlock() 272 273 + s.logger.Printf("Successfully refreshed token for user %d", userID) 274 return tokenResponse.AccessToken, nil 275 } 276 ··· 285 func (s *SpotifyService) RefreshExpiredTokens() { 286 users, err := s.DB.GetUsersWithExpiredTokens() 287 if err != nil { 288 + s.logger.Printf("Error fetching users with expired tokens: %v", err) 289 return 290 } 291 ··· 300 301 if err != nil { 302 // just print out errors here for now 303 + s.logger.Printf("Error from service/spotify/spotify.go when refreshing tokens: %s", err.Error()) 304 } 305 306 refreshed++ 307 } 308 309 if refreshed > 0 { 310 + s.logger.Printf("Refreshed tokens for %d users", refreshed) 311 } 312 } 313 ··· 368 tracks, err := s.DB.GetRecentTracks(userID, 20) 369 if err != nil { 370 http.Error(w, "Error retrieving track history", http.StatusInternalServerError) 371 + s.logger.Printf("Error retrieving track history: %v", err) 372 return 373 } 374 ··· 410 411 // oops, token expired or other client error 412 if resp.StatusCode == 401 && attempt == 0 { // Only refresh on 401 on the first attempt 413 + s.logger.Printf("Spotify token potentially expired for user %d, attempting refresh...", userID) 414 newAccessToken, refreshErr := s.refreshTokenInner(userID) 415 if refreshErr != nil { 416 + s.logger.Printf("Token refresh failed for user %d: %v", userID, refreshErr) 417 // No point retrying if refresh failed 418 return nil, fmt.Errorf("spotify token expired or invalid for user %d and refresh failed: %w", userID, refreshErr) 419 } 420 + s.logger.Printf("Token refreshed for user %d, retrying request...", userID) 421 token = newAccessToken // Update token for the next attempt 422 req.Header.Set("Authorization", "Bearer "+token) // Update header for retry 423 continue // Go to next attempt in the loop ··· 471 } `json:"external_urls"` 472 DurationMs int `json:"duration_ms"` 473 } `json:"item"` 474 + ProgressMS int `json:"progress_ms"` 475 + IsPlaying bool `json:"is_playing"` 476 } 477 478 err = json.Unmarshal(bodyBytes, &response) // Use bodyBytes here 479 if err != nil { 480 return nil, fmt.Errorf("failed to unmarshal spotify response: %w", err) 481 } 482 + if response.IsPlaying == false { 483 + return nil, nil 484 + } 485 var artists []models.Artist 486 for _, artist := range response.Item.Artists { 487 artists = append(artists, models.Artist{ ··· 507 return track, nil 508 } 509 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() 518 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 523 + } 524 + 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 } 530 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 + } 537 } 538 + continue 539 + } 540 541 + s.mu.RLock() 542 + currentTrack := s.userTracks[userID] 543 + s.mu.RUnlock() 544 545 + if currentTrack == nil { 546 + currentTracks, _ := s.DB.GetRecentTracks(userID, 1) 547 + if len(currentTracks) > 0 { 548 + currentTrack = currentTracks[0] 549 } 550 + } 551 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 557 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 560 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 564 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) 575 576 + s.logger.Printf("Updated!") 577 } 578 + } 579 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) 590 591 + // Update in memory 592 + s.mu.Lock() 593 + s.userTracks[userID] = track 594 + s.mu.Unlock() 595 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 + } 601 } 602 + 603 + s.logger.Printf("Updated!") 604 } 605 + } 606 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 + } 613 614 + track.PlayID = id 615 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 + } 626 + 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 } else { 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 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 + } 652 653 + artistName := "Unknown Artist" 654 + if len(trackToSubmitToPDS.Artist) > 0 { 655 + artistName = trackToSubmitToPDS.Artist[0].Name 656 + } 657 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) 664 } 665 + } 666 + // End of PDS submission block 667 668 + artistName := "Unknown Artist" 669 + if len(track.Artist) > 0 { 670 + artistName = track.Artist[0].Name 671 } 672 + s.logger.Printf("User %d is listening to: %s by %s", userID, track.Name, artistName) 673 + } 674 + } 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 }
+22 -29
session/session.go
··· 17 18 // session/session.go 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 27 } 28 29 type SessionManager struct { ··· 38 _, err := database.Exec(` 39 CREATE TABLE IF NOT EXISTS sessions ( 40 id TEXT PRIMARY KEY, 41 - user_id INTEGER NOT NULL, 42 created_at TIMESTAMP, 43 expires_at TIMESTAMP, 44 FOREIGN KEY (user_id) REFERENCES users(id) ··· 58 } 59 60 // create a new session for a user 61 - func (sm *SessionManager) CreateSession(userID int64) *Session { 62 sm.mu.Lock() 63 defer sm.mu.Unlock() 64 ··· 71 expiresAt := now.Add(24 * time.Hour) // 24-hour session 72 73 session := &Session{ 74 - ID: sessionID, 75 - UserID: userID, 76 - CreatedAt: now, 77 - ExpiresAt: expiresAt, 78 } 79 80 // store session in memory ··· 83 // store session in database if available 84 if sm.db != nil { 85 _, err := sm.db.Exec(` 86 - INSERT INTO sessions (id, user_id, created_at, expires_at) 87 - VALUES (?, ?, ?, ?)`, 88 - sessionID, userID, now, expiresAt) 89 90 if err != nil { 91 log.Printf("Error storing session in database: %v", err) ··· 116 session = &Session{ID: sessionID} 117 118 err := sm.db.QueryRow(` 119 - SELECT user_id, created_at, expires_at 120 FROM sessions WHERE id = ?`, sessionID).Scan( 121 - &session.UserID, &session.CreatedAt, &session.ExpiresAt) 122 123 if err != nil { 124 return nil, false ··· 178 MaxAge: -1, 179 } 180 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 } 193 194 func (sm *SessionManager) GetAPIKeyManager() *apikey.ApiKeyManager {
··· 17 18 // session/session.go 19 type Session struct { 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 29 } 30 31 type SessionManager struct { ··· 40 _, err := database.Exec(` 41 CREATE TABLE IF NOT EXISTS sessions ( 42 id TEXT PRIMARY KEY, 43 + user_id INTEGER NOT NULL, 44 + at_proto_session_id TEXT NOT NULL, 45 created_at TIMESTAMP, 46 expires_at TIMESTAMP, 47 FOREIGN KEY (user_id) REFERENCES users(id) ··· 61 } 62 63 // create a new session for a user 64 + func (sm *SessionManager) CreateSession(userID int64, atProtoSessionId string) *Session { 65 sm.mu.Lock() 66 defer sm.mu.Unlock() 67 ··· 74 expiresAt := now.Add(24 * time.Hour) // 24-hour session 75 76 session := &Session{ 77 + ID: sessionID, 78 + UserID: userID, 79 + ATProtoSessionID: atProtoSessionId, 80 + CreatedAt: now, 81 + ExpiresAt: expiresAt, 82 } 83 84 // store session in memory ··· 87 // store session in database if available 88 if sm.db != nil { 89 _, err := sm.db.Exec(` 90 + INSERT INTO sessions (id, user_id, at_proto_session_id, created_at, expires_at) 91 + VALUES (?, ?, ?, ?, ?)`, 92 + sessionID, userID, atProtoSessionId, now, expiresAt) 93 94 if err != nil { 95 log.Printf("Error storing session in database: %v", err) ··· 120 session = &Session{ID: sessionID} 121 122 err := sm.db.QueryRow(` 123 + SELECT user_id, at_proto_session_id, created_at, expires_at 124 FROM sessions WHERE id = ?`, sessionID).Scan( 125 + &session.UserID, &session.ATProtoSessionID, &session.CreatedAt, &session.ExpiresAt) 126 127 if err != nil { 128 return nil, false ··· 182 MaxAge: -1, 183 } 184 http.SetCookie(w, cookie) 185 } 186 187 func (sm *SessionManager) GetAPIKeyManager() *apikey.ApiKeyManager {
+1
util/gencbor/gencbor.go
··· 28 teal.AlphaActorStatus{}, 29 teal.AlphaActorProfile_FeaturedItem{}, 30 teal.AlphaFeedDefs_PlayView{}, 31 ); err != nil { 32 panic(err) 33 }
··· 28 teal.AlphaActorStatus{}, 29 teal.AlphaActorProfile_FeaturedItem{}, 30 teal.AlphaFeedDefs_PlayView{}, 31 + teal.AlphaFeedDefs_Artist{}, 32 ); err != nil { 33 panic(err) 34 }