A website inspired by Last.fm that will keep track of your listening statistics
lastfm music statistics
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add a basic artist page with some global stats

oscar345 f4397da0 d8c44110

+218 -49
+13 -6
client/src/routes/app/catalog/artists/[id]/+page.server.ts
··· 1 1 import { api, call } from "$lib"; 2 - import { GET_ArtistsByID, GET_ArtistsByIDCount } from "$lib/.gen/routes"; 3 - import type { Artist, Count } from "$lib/.gen/schemas/responses"; 4 - import type { Count as CountRequest } from "$lib/.gen/schemas/requests"; 2 + import { GET_ArtistsByID, GET_ArtistsByIDCount, GET_Recordings } from "$lib/.gen/routes"; 3 + import type { Artist, Count, Page, Recording } from "$lib/.gen/schemas/responses"; 4 + import type { Count as CountRequest, RecordingCount } from "$lib/.gen/schemas/requests"; 5 5 import type { PageServerLoad } from "./$types"; 6 6 7 7 export const load: PageServerLoad = async ({ params }) => { ··· 11 11 const lastSevenDays = call(api, GET_ArtistsByIDCount(params.id), { 12 12 searchParams: { 13 13 query: JSON.stringify({ 14 - from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), 14 + from: new Date(Date.now() - (7 + 700) * 24 * 60 * 60 * 1000).toISOString(), 15 15 } as CountRequest), 16 16 }, 17 17 }).json<Count>(); ··· 19 19 const lastThirtyDays = call(api, GET_ArtistsByIDCount(params.id), { 20 20 searchParams: { 21 21 query: JSON.stringify({ 22 - from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), 22 + from: new Date(Date.now() - (30 + 700) * 24 * 60 * 60 * 1000).toISOString(), 23 23 } as CountRequest), 24 24 }, 25 25 }).json<Count>(); ··· 27 27 const lastYear = call(api, GET_ArtistsByIDCount(params.id), { 28 28 searchParams: { 29 29 query: JSON.stringify({ 30 - from: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString(), 30 + from: new Date(Date.now() - (365 + 700) * 24 * 60 * 60 * 1000).toISOString(), 31 31 } as CountRequest), 32 32 }, 33 33 }).json<Count>(); 34 34 35 + const recordings = call(api, GET_Recordings(), { 36 + searchParams: { 37 + query: JSON.stringify({ artist_mbid: params.id } as RecordingCount), 38 + }, 39 + }).json<Page<Recording>>(); 40 + 35 41 return { 36 42 artist, 37 43 totalCount, 38 44 lastSevenDays, 39 45 lastThirtyDays, 40 46 lastYear, 47 + recordings, 41 48 }; 42 49 };
+26
client/src/routes/app/catalog/artists/[id]/+page.svelte
··· 1 1 <script lang="ts"> 2 + import { resolve } from "$app/paths"; 2 3 import type { PageProps } from "./$types"; 3 4 4 5 let { data, form }: PageProps = $props(); ··· 57 58 <p class="label">Plays of all users in the last year</p> 58 59 </article> 59 60 </section> 61 + 62 + <section> 63 + <ul> 64 + {#await data.recordings then recordings} 65 + {#each recordings.items as recording} 66 + <li> 67 + <a 68 + href={resolve("/app/catalog/recordings/[id]", { 69 + id: recording.mbid, 70 + })} 71 + >{recording.name} 72 + </a>- {recording.count} 73 + </li> 74 + {/each} 75 + {/await} 76 + </ul> 77 + </section> 60 78 </main> 61 79 62 80 <style> ··· 68 86 @container (width >= 48rem) { 69 87 grid-template-columns: repeat(4, 1fr); 70 88 } 89 + } 90 + 91 + li > a { 92 + color: var(--color-primary); 93 + } 94 + 95 + li > a:hover { 96 + text-decoration: underline; 71 97 } 72 98 73 99 article {
client/src/routes/app/catalog/recordings/[id]/+page.svelte

This is a binary file and will not be displayed.

+1 -1
cmd/bridge/main.go
··· 36 36 return errors.New("path is required") 37 37 } 38 38 39 - router := router.New(services.ArtistService{}, services.UserService{}, &config.Config{}) 39 + router := router.New(services.ArtistService{}, services.UserService{}, services.RecordingService{}, &config.Config{}) 40 40 bridge.CreateRoutes(router.Router(), c.path) 41 41 42 42 return nil
+1
internal/models/catalog.go
··· 17 17 type Recording struct { 18 18 MBID string 19 19 Name string 20 + Count int 20 21 Artists []ArtistCreditName 21 22 } 22 23
+50 -15
internal/repo/db/recording_scrobble.go
··· 25 25 26 26 func recordingScrobbleCountWhere(filter filters.RecordingCount, wheres []string, args []any) ([]string, []any) { 27 27 if filter.UserID != 0 { 28 - wheres = append(wheres, "scrobble.user_id = ?") 28 + wheres = append(wheres, "scrobbles.user_id = ?") 29 29 args = append(args, filter.UserID) 30 30 } 31 31 32 32 if !filter.From.IsZero() { 33 - wheres = append(wheres, "scrobble.played_at >= ?") 33 + wheres = append(wheres, "scrobbles.played_at >= ?") 34 34 args = append(args, filter.From.UnixMicro()) 35 35 } 36 36 37 37 if !filter.To.IsZero() { 38 - wheres = append(wheres, "scrobble.played_at <= ?") 38 + wheres = append(wheres, "scrobbles.played_at <= ?") 39 39 args = append(args, filter.To.UnixMicro()) 40 40 } 41 41 ··· 44 44 45 45 func (repo *RecordingScrobbleRepoDB) ListCount(ctx context.Context, filter filters.RecordingCount) ([]models.CountMBID, pagination.Page, error) { 46 46 var statement = /*sql*/ ` 47 - WITH scrobbles AS ( 47 + WITH scrobbles_with_artists AS ( 48 + SELECT 49 + scrobble.* 50 + FROM scrobble 51 + INNER JOIN recording_mbid__artist_mbid AS ra ON ra.recording_mbid = scrobble.recording_mbid 52 + WHERE ra.artist_mbid = ? 53 + ), 54 + scrobbles_default AS ( 55 + SELECT scrobble.* FROM scrobble 56 + ), 57 + counts AS ( 48 58 SELECT 49 - scrobble.recording_mbid AS mbid, 50 - count(scrobble) AS amount 51 - FROM scrobble 59 + scrobbles.recording_mbid AS mbid, 60 + count(scrobbles) AS amount 61 + FROM %s as scrobbles 52 62 WHERE %s 53 - GROUP BY scrobble.recording_mbid 63 + GROUP BY scrobbles.recording_mbid 54 64 ) 55 65 SELECT 56 - scrobbles.mbid, 57 - scrobbles.amount, 66 + counts.mbid, 67 + counts.amount, 58 68 COUNT(*) OVER () AS total 59 - FROM scrobbles 69 + FROM counts 60 70 ORDER BY amount DESC 61 - OFFSET ? LIMIT ?; 71 + LIMIT ? OFFSET ?; 62 72 ` 63 73 64 74 args := []any{} 75 + wheres := []string{} 76 + cte := "scrobbles_default" 77 + args = append(args, filter.ArtistMBID) 78 + if filter.ArtistMBID != "" { 79 + cte = "scrobbles_with_artists" 80 + fmt.Println("after adding artist", args) 81 + } 82 + fmt.Println("after adding filter", args) 83 + 84 + wheres, args = recordingScrobbleCountWhere(filter, wheres, args) 85 + fmt.Println("after adding where", args) 86 + 87 + args = append(args, filter.Pagination.Limit(), filter.Pagination.Offset()) 88 + fmt.Println("after adding pagination", args) 89 + 90 + where := "TRUE" 91 + if len(wheres) > 0 { 92 + where = strings.Join(wheres, " AND ") 93 + } 94 + statement = fmt.Sprintf(statement, cte, where) 95 + fmt.Println(statement, args) 65 96 66 97 var total int 67 - database.QueryMany(ctx, repo.db, statement, args, func(r *sql.Rows) (models.CountMBID, error) { 98 + items, err := database.QueryMany(ctx, repo.db, statement, args, func(r *sql.Rows) (models.CountMBID, error) { 68 99 var model models.CountMBID 69 100 if err := r.Scan(&model.MBID, &model.Count, &total); err != nil { 70 101 return model, err ··· 72 103 return model, nil 73 104 }) 74 105 75 - return nil, pagination.Page{}, nil 106 + if err != nil { 107 + return nil, pagination.Page{}, err 108 + } 109 + 110 + return items, pagination.New(filter.Pagination, total), nil 76 111 } 77 112 78 113 func (repo *RecordingScrobbleRepoDB) GetCount(ctx context.Context, mbid string, filter filters.RecordingCount) (int, error) { 79 114 var statement = /*sql*/ ` 80 - SELECT COUNT(*) FROM scrobbles 115 + SELECT COUNT(*) FROM scrobble as scrobbles 81 116 WHERE %s 82 117 ` 83 118 args := []any{mbid}
+10 -3
internal/server/server.go
··· 50 50 services := s.services(generalDB, statisticsDB) 51 51 52 52 router := router. 53 - New(services.Artist, services.User, s.config). 53 + New(services.Artist, services.User, services.Recording, s.config). 54 54 Router() 55 55 56 56 log.Printf("Listening on http://%s\n", s.address) ··· 59 59 } 60 60 61 61 type Services struct { 62 - Artist services.ArtistService 63 - User services.UserService 62 + Artist services.ArtistService 63 + User services.UserService 64 + Recording services.RecordingService 65 + Release services.ReleaseService 64 66 } 65 67 66 68 func (s *Server) services(generalDB *sql.DB, statisticsDB *sql.DB) Services { ··· 71 73 userRepo := db.NewUserRepoDB(generalDB) 72 74 userFollowRepo := db.NewUserFollowRepoDB(generalDB) 73 75 userServiceRepo := db.NewUserServiceRepoDB(generalDB) 76 + 77 + recordingRepo := db.NewRecordingRepoDB(generalDB) 78 + recordingLikeRepo := db.NewRecordingLikeRepo(generalDB) 79 + recordingScrobbleRepo := db.NewRecordingScrobbleRepoDB(statisticsDB) 74 80 75 81 releaseImageFetcher := image.NewReleaseImageFetcherCoverArtArchive() 76 82 ··· 90 96 artistScrobbleRepo, 91 97 mediaProvider, 92 98 ), 99 + Recording: services.NewRecordingService(recordingRepo, recordingLikeRepo, recordingScrobbleRepo), 93 100 } 94 101 }
+3 -8
internal/services/artist.go
··· 82 82 83 83 items := enum.Map(counts, func(item models.CountMBID) models.Artist { 84 84 artist := artists[item.MBID] 85 - image := images[item.MBID] 86 - 87 - return models.Artist{ 88 - MBID: item.MBID, 89 - Name: artist.Name, 90 - Count: item.Count, 91 - ImageURL: image, 92 - } 85 + artist.ImageURL = images[item.MBID] 86 + artist.Count = item.Count 87 + return artist 93 88 }) 94 89 95 90 return items, page, nil
+37 -5
internal/services/recording.go
··· 1 1 package services 2 2 3 3 import ( 4 + "context" 5 + 6 + "github.com/oscar345/keeptrack/internal/filters" 7 + "github.com/oscar345/keeptrack/internal/models" 4 8 "github.com/oscar345/keeptrack/internal/repo" 9 + "github.com/oscar345/keeptrack/pkg/enum" 10 + "github.com/oscar345/keeptrack/pkg/pagination" 5 11 ) 6 12 7 13 type RecordingRepos struct { 8 14 recording repo.RecordingRepo 9 15 like repo.RecordingLikeRepo 16 + scrobble repo.RecordingScrobbleRepo 10 17 } 11 18 12 19 type RecordingService struct { 13 20 repos RecordingRepos 14 21 } 15 22 16 - func NewRecordingService(recordingRepo repo.RecordingRepo, recordingLikeRepo repo.RecordingLikeRepo) *RecordingService { 17 - return &RecordingService{ 23 + func NewRecordingService( 24 + recordingRepo repo.RecordingRepo, 25 + recordingLikeRepo repo.RecordingLikeRepo, 26 + recordingScrobbleRepo repo.RecordingScrobbleRepo, 27 + ) RecordingService { 28 + return RecordingService{ 18 29 repos: RecordingRepos{ 19 30 like: recordingLikeRepo, 20 31 recording: recordingRepo, 32 + scrobble: recordingScrobbleRepo, 21 33 }, 22 34 } 23 35 } 24 36 25 - // func (rs *RecordingService) ListByCount(ctx context.Context, filter filters.RecordingCount) error { 26 - // filter.UserID = 0 27 - // } 37 + func (rs *RecordingService) ListByCount(ctx context.Context, filter filters.RecordingCount) ([]models.Recording, pagination.Page, error) { 38 + filter.UserID = 0 39 + 40 + counts, page, err := rs.repos.scrobble.ListCount(ctx, filter) 41 + if err != nil { 42 + return nil, page, err 43 + } 44 + 45 + recordings, err := rs.repos.recording.ListByIDs(ctx, enum.Map(counts, func(item models.CountMBID) string { 46 + return item.MBID 47 + })) 48 + if err != nil { 49 + return nil, page, err 50 + } 51 + 52 + result := enum.Map(counts, func(item models.CountMBID) models.Recording { 53 + recording := recordings[item.MBID] 54 + recording.Count = item.Count 55 + return recording 56 + }) 57 + 58 + return result, page, err 59 + }
+21
internal/web/requests/catalog.go
··· 30 30 31 31 return filter, nil 32 32 } 33 + 34 + type RecordingCount struct { 35 + Pagination 36 + From time.Time `json:"from"` 37 + To time.Time `json:"to"` 38 + ArtistMBID string `json:"artist_mbid"` 39 + } 40 + 41 + func (req RecordingCount) Filter() (filters.RecordingCount, error) { 42 + filter := filters.RecordingCount{ 43 + Pagination: pagination.Filter{ 44 + Page: req.Pagination.Page, 45 + Size: req.Pagination.Size, 46 + }, 47 + From: req.From, 48 + To: req.To, 49 + ArtistMBID: req.ArtistMBID, 50 + } 51 + 52 + return filter, nil 53 + }
+25 -4
internal/web/responses/catalog.go
··· 2 2 3 3 import ( 4 4 "github.com/oscar345/keeptrack/internal/models" 5 + "github.com/oscar345/keeptrack/pkg/enum" 5 6 "github.com/oscar345/keeptrack/pkg/pagination" 6 7 ) 7 8 ··· 12 13 ImageURL string `json:"image_url"` 13 14 } 14 15 16 + type ArtistCreditName struct { 17 + Artist Artist `json:"artist"` 18 + Name string `json:"name"` 19 + Join string `json:"join"` 20 + Position int `json:"position"` 21 + } 22 + 23 + func NewArtistCreditNameFromModel(model models.ArtistCreditName) ArtistCreditName { 24 + return ArtistCreditName{ 25 + Artist: NewArtistFromModel(model.Artist), 26 + Name: model.Name, 27 + Join: model.JoinPhrase, 28 + Position: model.Position, 29 + } 30 + } 31 + 15 32 func NewArtistFromModel(model models.Artist) Artist { 16 33 return Artist{ 17 34 MBID: model.MBID, ··· 22 39 } 23 40 24 41 type Recording struct { 25 - MBID string `json:"mbid"` 26 - Name string `json:"name"` 42 + MBID string `json:"mbid"` 43 + Name string `json:"name"` 44 + Count int `json:"count"` 45 + Artists []ArtistCreditName `json:"artists"` 27 46 } 28 47 29 48 func NewRecordingFromModel(model models.Recording) Recording { 30 49 return Recording{ 31 - MBID: model.MBID, 32 - Name: model.Name, 50 + MBID: model.MBID, 51 + Name: model.Name, 52 + Count: model.Count, 53 + Artists: enum.Map(model.Artists, NewArtistCreditNameFromModel), 33 54 } 34 55 } 35 56
+31 -7
internal/web/router/router.go
··· 17 17 "github.com/oscar345/keeptrack/internal/web/middleware" 18 18 "github.com/oscar345/keeptrack/internal/web/requests" 19 19 "github.com/oscar345/keeptrack/internal/web/responses" 20 + "github.com/oscar345/keeptrack/pkg/enum" 20 21 "golang.org/x/oauth2" 21 22 "golang.org/x/oauth2/spotify" 22 23 ) 23 24 24 25 type Services struct { 25 - artist services.ArtistService 26 - user services.UserService 27 - release services.ReleaseService 26 + artist services.ArtistService 27 + user services.UserService 28 + recording services.RecordingService 28 29 } 29 30 30 31 type Server struct { ··· 35 36 func New( 36 37 artistService services.ArtistService, 37 38 userService services.UserService, 39 + recordingService services.RecordingService, 38 40 config *config.Config, 39 41 ) *Server { 40 42 return &Server{ 41 43 services: Services{ 42 - artist: artistService, 43 - user: userService, 44 + artist: artistService, 45 + user: userService, 46 + recording: recordingService, 44 47 }, 45 48 config: config, 46 49 } ··· 69 72 70 73 r.Group(s.index()) 71 74 r.Route("/artists", s.artists()) 75 + r.Route("/recordings", s.recordings()) 72 76 73 77 return r 74 78 } ··· 130 134 } 131 135 } 132 136 133 - func (s *Server) recordings() func (chi.Router) { 137 + func (s *Server) recordings() func(chi.Router) { 134 138 return func(r chi.Router) { 135 139 r.Get("/", func(w http.ResponseWriter, r *http.Request) { 136 - 140 + var request requests.RecordingCount 141 + query := r.URL.Query().Get("query") 142 + 143 + if query != "" { 144 + if err := json.Unmarshal([]byte(query), &request); err != nil { 145 + http.Error(w, err.Error(), http.StatusBadRequest) 146 + return 147 + } 148 + } 149 + 150 + filter, _ := request.Filter() 151 + 152 + items, page, err := s.services.recording.ListByCount(r.Context(), filter) 153 + if err != nil { 154 + http.Error(w, err.Error(), http.StatusInternalServerError) 155 + return 156 + } 157 + 158 + render.JSON(w, r, responses.Paginate( 159 + page, enum.Map(items, responses.NewRecordingFromModel), 160 + )) 137 161 }) 138 162 } 139 163 }