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.

With work on the repos in go, there is now the ability to get all the data for an artist page like counts for the date ranges, most played songs and most played releases from an artist

oscar345 c1aff634 f4397da0

+793 -179
+33
client/src/lib/components/catalog/ArtistCreditNames.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import type { ArtistCreditName } from "$lib/.gen/schemas/responses"; 4 + 5 + type Props = { 6 + artists: ArtistCreditName[]; 7 + }; 8 + 9 + let { artists }: Props = $props(); 10 + </script> 11 + 12 + <div> 13 + {#each artists as item} 14 + <a href={resolve("/app/catalog/artists/[id]", { id: item.artist.mbid })}> 15 + {item.name} 16 + </a> 17 + {#if item.join} 18 + <span>{item.join}</span> 19 + {/if} 20 + {/each} 21 + </div> 22 + 23 + <style> 24 + div { 25 + color: var(--color-content-300); 26 + font-size: var(--text-sm); 27 + line-height: var(--leading-tighter); 28 + } 29 + 30 + a:hover { 31 + text-decoration: underline; 32 + } 33 + </style>
+89
client/src/lib/components/catalog/RecordingListItem.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import type { Recording } from "$lib/.gen/schemas/responses"; 4 + import ArtistCreditNames from "$lib/components/catalog/ArtistCreditNames.svelte"; 5 + 6 + type Props = { 7 + recording: Recording; 8 + bottom?: "played_at" | "artists" | "none"; 9 + trailing?: "plays" | "none"; 10 + }; 11 + 12 + let { recording, bottom = "none", trailing = "none" }: Props = $props(); 13 + </script> 14 + 15 + <article> 16 + <div> 17 + <img 18 + src="http://localhost:3000/public/images/releases/image.jpg" 19 + alt="" 20 + /> 21 + </div> 22 + <div class="text"> 23 + <a href={resolve("/app/catalog/recordings/[id]", { id: recording.mbid })}> 24 + {recording.name} 25 + </a> 26 + {#if bottom === "artists"} 27 + <ArtistCreditNames artists={recording.artists} /> 28 + {/if} 29 + </div> 30 + 31 + {#if trailing !== "none"} 32 + <div class="trailing"> 33 + {#if trailing === "plays"} 34 + <span class="plays">{recording.count}</span> 35 + {/if} 36 + </div> 37 + {/if} 38 + </article> 39 + 40 + <style> 41 + article { 42 + display: grid; 43 + grid-template-columns: var(--spacing-10) 1fr max-content; 44 + column-gap: var(--spacing-2); 45 + } 46 + 47 + .plays { 48 + background-color: var(--color-secondary); 49 + color: var(--color-secondary-contrast); 50 + padding: var(--spacing-1); 51 + font-size: var(--text-xs); 52 + line-height: var(--leading-tighter); 53 + font-weight: var(--font-weight-medium); 54 + } 55 + 56 + .trailing { 57 + align-self: center; 58 + } 59 + 60 + .text { 61 + grid-column: 2 / 4; 62 + align-self: center; 63 + display: flex; 64 + flex-direction: column; 65 + gap: var(--spacing-0_5); 66 + } 67 + 68 + .text > a { 69 + color: var(--color-primary); 70 + font-size: var(--text-sm); 71 + line-height: var(--leading-tighter); 72 + 73 + &:hover { 74 + text-decoration: underline; 75 + } 76 + } 77 + 78 + .text > :global(*) { 79 + overflow: hidden; 80 + display: -webkit-box; 81 + -webkit-box-orient: vertical; 82 + -webkit-line-clamp: 1; 83 + line-clamp: 1; 84 + } 85 + 86 + article:has(> .trailing) > .text { 87 + grid-column: 2 / 3; 88 + } 89 + </style>
+89
client/src/lib/components/catalog/ReleaseListItem.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import type { Release } from "$lib/.gen/schemas/responses"; 4 + import ArtistCreditNames from "$lib/components/catalog/ArtistCreditNames.svelte"; 5 + 6 + type Props = { 7 + release: Release; 8 + bottom?: "artists" | "none"; 9 + trailing?: "plays" | "none"; 10 + }; 11 + 12 + let { release, bottom = "none", trailing = "none" }: Props = $props(); 13 + </script> 14 + 15 + <article> 16 + <div> 17 + <img 18 + src="http://localhost:3000/public/images/releases/image.jpg" 19 + alt="" 20 + /> 21 + </div> 22 + <div class="text"> 23 + <a href={resolve("/app/catalog/releases/[id]", { id: release.mbid })}> 24 + {release.name} 25 + </a> 26 + {#if bottom === "artists"} 27 + <ArtistCreditNames artists={release.artists} /> 28 + {/if} 29 + </div> 30 + 31 + {#if trailing !== "none"} 32 + <div class="trailing"> 33 + {#if trailing === "plays"} 34 + <span class="plays">{release.count}</span> 35 + {/if} 36 + </div> 37 + {/if} 38 + </article> 39 + 40 + <style> 41 + article { 42 + display: grid; 43 + grid-template-columns: var(--spacing-10) 1fr max-content; 44 + column-gap: var(--spacing-2); 45 + } 46 + 47 + .plays { 48 + background-color: var(--color-secondary); 49 + color: var(--color-secondary-contrast); 50 + padding: var(--spacing-1); 51 + font-size: var(--text-xs); 52 + line-height: var(--leading-tighter); 53 + font-weight: var(--font-weight-medium); 54 + } 55 + 56 + .trailing { 57 + align-self: center; 58 + } 59 + 60 + .text { 61 + grid-column: 2 / 4; 62 + align-self: center; 63 + display: flex; 64 + flex-direction: column; 65 + gap: var(--spacing-0_5); 66 + } 67 + 68 + .text > a { 69 + color: var(--color-primary); 70 + font-size: var(--text-sm); 71 + line-height: var(--leading-tighter); 72 + 73 + &:hover { 74 + text-decoration: underline; 75 + } 76 + } 77 + 78 + .text > :global(*) { 79 + overflow: hidden; 80 + display: -webkit-box; 81 + -webkit-box-orient: vertical; 82 + -webkit-line-clamp: 1; 83 + line-clamp: 1; 84 + } 85 + 86 + article:has(> .trailing) > .text { 87 + grid-column: 2 / 3; 88 + } 89 + </style>
+44 -25
client/src/routes/app/catalog/artists/[id]/+page.server.ts
··· 1 1 import { api, call } from "$lib"; 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"; 2 + import { 3 + GET_ArtistsByID, 4 + GET_ArtistsByIDCount, 5 + GET_Recordings, 6 + GET_Releases, 7 + } from "$lib/.gen/routes"; 8 + import { 9 + type Release, 10 + type Artist, 11 + type Count, 12 + type Page, 13 + type Recording, 14 + } from "$lib/.gen/schemas/responses"; 15 + import type { 16 + Count as CountRequest, 17 + RecordingCount, 18 + ReleaseCount, 19 + } from "$lib/.gen/schemas/requests"; 5 20 import type { PageServerLoad } from "./$types"; 6 21 7 22 export const load: PageServerLoad = async ({ params }) => { 8 23 const artist = await call(api, GET_ArtistsByID(params.id), {}).json<Artist>(); 24 + 25 + const getCount = (filter: CountRequest) => { 26 + return call(api, GET_ArtistsByIDCount(params.id), { 27 + searchParams: { 28 + query: JSON.stringify(filter), 29 + }, 30 + }).json<Count>(); 31 + }; 32 + 9 33 const totalCount = call(api, GET_ArtistsByIDCount(params.id), {}).json<Count>(); 10 34 11 - const lastSevenDays = call(api, GET_ArtistsByIDCount(params.id), { 12 - searchParams: { 13 - query: JSON.stringify({ 14 - from: new Date(Date.now() - (7 + 700) * 24 * 60 * 60 * 1000).toISOString(), 15 - } as CountRequest), 16 - }, 17 - }).json<Count>(); 35 + const lastSevenDays = getCount({ 36 + from: new Date(Date.now() - (7 + 700) * 24 * 60 * 60 * 1000).toISOString(), 37 + } as CountRequest); 38 + 39 + const lastThirtyDays = getCount({ 40 + from: new Date(Date.now() - (30 + 700) * 24 * 60 * 60 * 1000).toISOString(), 41 + } as CountRequest); 18 42 19 - const lastThirtyDays = call(api, GET_ArtistsByIDCount(params.id), { 20 - searchParams: { 21 - query: JSON.stringify({ 22 - from: new Date(Date.now() - (30 + 700) * 24 * 60 * 60 * 1000).toISOString(), 23 - } as CountRequest), 24 - }, 25 - }).json<Count>(); 43 + const lastYear = getCount({ 44 + from: new Date(Date.now() - (365 + 700) * 24 * 60 * 60 * 1000).toISOString(), 45 + } as CountRequest); 26 46 27 - const lastYear = call(api, GET_ArtistsByIDCount(params.id), { 47 + const recordings = call(api, GET_Recordings(), { 28 48 searchParams: { 29 - query: JSON.stringify({ 30 - from: new Date(Date.now() - (365 + 700) * 24 * 60 * 60 * 1000).toISOString(), 31 - } as CountRequest), 49 + query: JSON.stringify({ artist_mbid: params.id, pagination: { size: 10 } } as RecordingCount), 32 50 }, 33 - }).json<Count>(); 51 + }).json<Page<Recording>>(); 34 52 35 - const recordings = call(api, GET_Recordings(), { 53 + const releases = call(api, GET_Releases(), { 36 54 searchParams: { 37 - query: JSON.stringify({ artist_mbid: params.id } as RecordingCount), 55 + query: JSON.stringify({ artist_mbid: params.id, pagination: { size: 10 } } as ReleaseCount), 38 56 }, 39 - }).json<Page<Recording>>(); 57 + }).json<Page<Release>>(); 40 58 41 59 return { 42 60 artist, ··· 45 63 lastThirtyDays, 46 64 lastYear, 47 65 recordings, 66 + releases, 48 67 }; 49 68 };
+42 -17
client/src/routes/app/catalog/artists/[id]/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { resolve } from "$app/paths"; 2 + import RecordingListItem from "$lib/components/catalog/RecordingListItem.svelte"; 3 + import ReleaseListItem from "$lib/components/catalog/ReleaseListItem.svelte"; 3 4 import type { PageProps } from "./$types"; 4 5 5 6 let { data, form }: PageProps = $props(); ··· 59 60 </article> 60 61 </section> 61 62 62 - <section> 63 - <ul> 63 + <div class="catalog"> 64 + <section> 65 + <header class="header"> 66 + <hgroup> 67 + <h2 class="h2">Recordings</h2> 68 + </hgroup> 69 + </header> 64 70 {#await data.recordings then recordings} 65 71 {#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> 72 + <RecordingListItem 73 + {recording} 74 + bottom="artists" 75 + trailing="plays" 76 + /> 77 + {/each} 78 + {/await} 79 + </section> 80 + <section> 81 + <header class="header"> 82 + <hgroup> 83 + <h2 class="h2">Releases</h2> 84 + </hgroup> 85 + </header> 86 + {#await data.releases then releases} 87 + {#each releases.items as release} 88 + <ReleaseListItem {release} bottom="artists" trailing="plays" /> 74 89 {/each} 75 90 {/await} 76 - </ul> 77 - </section> 91 + </section> 92 + </div> 78 93 </main> 79 94 80 95 <style> ··· 88 103 } 89 104 } 90 105 91 - li > a { 92 - color: var(--color-primary); 106 + .catalog { 107 + display: grid; 108 + grid-template-columns: 1fr; 109 + gap: var(--spacing-section-gap-y); 110 + 111 + @container (width >= 48rem) { 112 + grid-template-columns: repeat(2, 1fr); 113 + } 93 114 } 94 115 95 - li > a:hover { 96 - text-decoration: underline; 116 + .catalog > section { 117 + display: flex; 118 + flex-direction: column; 119 + gap: var(--spacing-section-gap-y); 120 + border: var(--theme-default-border); 121 + padding: var(--spacing-article-padding); 97 122 } 98 123 99 124 article {
client/src/routes/app/catalog/releases/[id]/+page.svelte

This is a binary file and will not be displayed.

+6
client/src/routes/app/dashboard/+page.svelte
··· 27 27 <Link href="/app/catalog/artists/a74b1b7f-71a5-4011-9441-d0b5e4122711"> 28 28 radiohead 29 29 </Link> 30 + <Link href="/app/catalog/artists/e21857d5-3256-4547-afb3-4b6ded592596"> 31 + gorillaz 32 + </Link> 33 + <Link href="/app/catalog/artists/20244d07-534f-4eff-b4d4-930878889970"> 34 + taylor swift 35 + </Link> 30 36 </section> 31 37 </main> 32 38
+7 -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{}, services.RecordingService{}, &config.Config{}) 39 + router := router.New( 40 + services.ArtistService{}, 41 + services.UserService{}, 42 + services.RecordingService{}, 43 + services.ReleaseService{}, 44 + &config.Config{}, 45 + ) 40 46 bridge.CreateRoutes(router.Router(), c.path) 41 47 42 48 return nil
+9
internal/filters/catalog.go
··· 14 14 } 15 15 16 16 type RecordingCount struct { 17 + Pagination pagination.Filter 18 + From time.Time 19 + To time.Time 20 + ArtistMBID string 21 + ReleaseMBID string 22 + UserID int 23 + } 24 + 25 + type ReleaseCount struct { 17 26 Pagination pagination.Filter 18 27 From time.Time 19 28 To time.Time
+4 -2
internal/models/catalog.go
··· 22 22 } 23 23 24 24 type Release struct { 25 - MBID string 26 - Name string 25 + MBID string 26 + Name string 27 + Count int 28 + Artists []ArtistCreditName 27 29 }
+29 -3
internal/repo/db/artist.go
··· 25 25 var statement = /*sql*/ ` 26 26 SELECT artist.gid, artist.name FROM artist WHERE artist.gid IN (%s); 27 27 ` 28 - statement = fmt.Sprintf(statement, database.GeneratePlaceholders(len(mbids))) 28 + statement = fmt.Sprintf(statement, database.Placeholders(len(mbids))) 29 29 30 - args := []any{mbids} 31 - args = database.ExpandArgs(args) 30 + var args = make([]any, len(mbids)) 31 + args = database.AppendMany(args, mbids) 32 32 33 33 items, err := database.QueryMany(ctx, repo.db, statement, args, func(r *sql.Rows) (models.Artist, error) { 34 34 var artist models.Artist ··· 59 59 return artist, nil 60 60 }) 61 61 } 62 + 63 + // Returns a part of the query that can be used to retrieve the artists along with their artist 64 + // credit name value based on the artist credit id that is passed as an argument. For a recording 65 + // one should therefore pass the following value: `recording.artist_credit`, for a release group, it 66 + // would be release_group.artist_credit 67 + func artistCreditNamesJsonStatement(artistCreditColumn string) string { 68 + const statement = /*sql*/ ` 69 + SELECT JSON_GROUP_ARRAY( 70 + JSON_OBJECT( 71 + 'name', artist_credit_name.name, 72 + 'join_phrase', artist_credit_name.join_phrase, 73 + 'position', artist_credit_name.position, 74 + 'artist', JSON_OBJECT( 75 + 'mbid', artist.gid, 76 + 'name', artist.name 77 + ) 78 + ) 79 + ) 80 + FROM artist_credit 81 + INNER JOIN artist_credit_name ON artist_credit.id = artist_credit_name.artist_credit 82 + INNER JOIN artist ON artist_credit_name.artist = artist.id 83 + WHERE artist_credit.id = %s 84 + ` 85 + 86 + return fmt.Sprintf(statement, artistCreditColumn) 87 + }
+12 -23
internal/repo/db/artist_scrobble.go
··· 4 4 "context" 5 5 "database/sql" 6 6 "fmt" 7 - "log" 8 - "strings" 9 7 10 8 "github.com/oscar345/keeptrack/internal/filters" 11 9 "github.com/oscar345/keeptrack/internal/models" ··· 65 63 LIMIT ?; 66 64 ` 67 65 68 - wheres := []string{} 69 - args := []any{} 70 - 71 - wheres, args = artistScrobbleCountWhere(filter, wheres, args) 72 - 73 - where := "TRUE" 74 - if len(wheres) > 0 { 75 - where = strings.Join(wheres, " AND ") 76 - } 66 + wheres, args := database.Where(make([]string, 0), make([]any, 0), 67 + database.Condition{SQL: "scrobble.user_id = ?", Value: filter.UserID, Ok: filter.UserID != 0}, 68 + database.Condition{SQL: "scrobble.played_at >= ?", Value: filter.From, Ok: !filter.From.IsZero()}, 69 + database.Condition{SQL: "scrobble.played_at <= ?", Value: filter.To, Ok: !filter.To.IsZero()}, 70 + ) 77 71 78 - statement = fmt.Sprintf(statement, where) 72 + statement = fmt.Sprintf(statement, database.WhereSQL(wheres)) 79 73 args = append(args, filter.Pagination.Offset(), filter.Pagination.Limit()) 80 74 81 75 var total int ··· 109 103 FROM scrobbles 110 104 ` 111 105 112 - wheres := []string{} 113 106 args := []any{mbid} 114 - 115 - wheres, args = artistScrobbleCountWhere(filter, wheres, args) 107 + wheres, args := database.Where(make([]string, 0), args, 108 + database.Condition{SQL: "scrobble.user_id = ?", Value: filter.UserID, Ok: filter.UserID != 0}, 109 + database.Condition{SQL: "scrobble.played_at >= ?", Value: filter.From, Ok: !filter.From.IsZero()}, 110 + database.Condition{SQL: "scrobble.played_at <= ?", Value: filter.To, Ok: !filter.To.IsZero()}, 111 + ) 116 112 117 - where := "TRUE" 118 - if len(wheres) > 0 { 119 - where = strings.Join(wheres, " AND ") 120 - } 121 - 122 - statement = fmt.Sprintf(statement, where) 123 - 124 - log.Printf("statement: %s", statement) 113 + statement = fmt.Sprintf(statement, database.WhereSQL(wheres)) 125 114 126 115 return database.QueryOne(ctx, repo.duck, statement, args, func(r *sql.Rows) (int, error) { 127 116 var amount int
+9 -20
internal/repo/db/recording.go
··· 37 37 } 38 38 39 39 func (repo *RecordingRepoDB) ListByIDs(ctx context.Context, mbids []string) (map[string]models.Recording, error) { 40 + var args = make([]any, 0) 41 + 40 42 var statement = /*sql*/ ` 41 43 SELECT 42 44 recording.gid, 43 45 recording.name, 44 - ( 45 - SELECT JSON_ARRAY( 46 - JSON_OBJECT( 47 - 'name', artist_credit_name.name, 48 - 'join_phrase', artist_credit_name.join_phrase, 49 - 'position', artist_credit_name.position, 50 - 'artist', JSON_OBJECT( 51 - 'mbid', artist.gid, 52 - 'name', artist.name 53 - ) 54 - ) 55 - ) 56 - FROM artist_credit 57 - INNER JOIN artist_credit_name ON artist_credit.id = artist_credit_name.artist_credit 58 - INNER JOIN artist ON artist_credit_name.artist = artist.id 59 - WHERE artist_credit.id = recording.artist_credit 60 - ) as artists 46 + (%s) as artists 61 47 FROM recording 62 48 WHERE recording.gid IN (%s); 63 49 ` 64 - statement = fmt.Sprintf(statement, database.GeneratePlaceholders(len(mbids))) 65 50 66 - args := []any{mbids} 67 - args = database.ExpandArgs(args) 51 + args = database.AppendMany(args, mbids) 52 + statement = fmt.Sprintf( 53 + statement, 54 + artistCreditNamesJsonStatement("recording.artist_credit"), 55 + database.Placeholders(len(mbids)), 56 + ) 68 57 69 58 items, err := database.QueryMany(ctx, repo.db, statement, args, func(r *sql.Rows) (models.Recording, error) { 70 59 var recording models.Recording
+40 -53
internal/repo/db/recording_scrobble.go
··· 4 4 "context" 5 5 "database/sql" 6 6 "fmt" 7 - "strings" 8 7 9 8 "github.com/oscar345/keeptrack/internal/filters" 10 9 "github.com/oscar345/keeptrack/internal/models" ··· 23 22 24 23 var _ repo.RecordingScrobbleRepo = (*RecordingScrobbleRepoDB)(nil) 25 24 26 - func recordingScrobbleCountWhere(filter filters.RecordingCount, wheres []string, args []any) ([]string, []any) { 27 - if filter.UserID != 0 { 28 - wheres = append(wheres, "scrobbles.user_id = ?") 29 - args = append(args, filter.UserID) 30 - } 25 + func (repo *RecordingScrobbleRepoDB) ListCount(ctx context.Context, filter filters.RecordingCount) ([]models.CountMBID, pagination.Page, error) { 26 + var args = make([]any, 0) 31 27 32 - if !filter.From.IsZero() { 33 - wheres = append(wheres, "scrobbles.played_at >= ?") 34 - args = append(args, filter.From.UnixMicro()) 35 - } 36 - 37 - if !filter.To.IsZero() { 38 - wheres = append(wheres, "scrobbles.played_at <= ?") 39 - args = append(args, filter.To.UnixMicro()) 40 - } 41 - 42 - return wheres, args 43 - } 44 - 45 - func (repo *RecordingScrobbleRepoDB) ListCount(ctx context.Context, filter filters.RecordingCount) ([]models.CountMBID, pagination.Page, error) { 46 - var statement = /*sql*/ ` 47 - WITH scrobbles_with_artists AS ( 48 - SELECT 28 + var source string 29 + switch { 30 + case filter.ArtistMBID != "": 31 + source = /*sql*/ `( 32 + SELECT 49 33 scrobble.* 50 34 FROM scrobble 51 35 INNER JOIN recording_mbid__artist_mbid AS ra ON ra.recording_mbid = scrobble.recording_mbid 52 36 WHERE ra.artist_mbid = ? 53 - ), 54 - scrobbles_default AS ( 55 - SELECT scrobble.* FROM scrobble 56 - ), 37 + )` 38 + args = append(args, filter.ArtistMBID) 39 + case filter.ReleaseMBID != "": 40 + source = /*sql*/ `( 41 + SELECT 42 + scrobble.* 43 + FROM scrobble 44 + INNER JOIN recording_mbid__release_mbid AS rr ON rr.recording_mbid = scrobble.recording_mbid 45 + WHERE rr.release_mbid = ? 46 + )` 47 + args = append(args, filter.ReleaseMBID) 48 + default: 49 + source = /*sql*/ `(SELECT scrobble.* FROM scrobble)` 50 + } 51 + 52 + var statement = /*sql*/ ` 53 + WITH source AS %s, 57 54 counts AS ( 58 55 SELECT 59 56 scrobbles.recording_mbid AS mbid, 60 57 count(scrobbles) AS amount 61 - FROM %s as scrobbles 58 + FROM source as scrobbles 62 59 WHERE %s 63 60 GROUP BY scrobbles.recording_mbid 64 61 ) ··· 71 68 LIMIT ? OFFSET ?; 72 69 ` 73 70 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 - 71 + wheres, args := database.Where(make([]string, 0), args, 72 + database.Condition{SQL: "scrobbles.played_at <= ?", Ok: !filter.To.IsZero(), Value: filter.To}, 73 + database.Condition{SQL: "scrobbles.played_at >= ?", Ok: !filter.From.IsZero(), Value: filter.From}, 74 + database.Condition{SQL: "scrobbles.user_id = ?", Ok: filter.UserID != 0, Value: filter.UserID}, 75 + ) 87 76 args = append(args, filter.Pagination.Limit(), filter.Pagination.Offset()) 88 - fmt.Println("after adding pagination", args) 89 77 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) 78 + statement = fmt.Sprintf(statement, source, database.WhereSQL(wheres)) 96 79 97 80 var total int 98 81 items, err := database.QueryMany(ctx, repo.db, statement, args, func(r *sql.Rows) (models.CountMBID, error) { ··· 112 95 113 96 func (repo *RecordingScrobbleRepoDB) GetCount(ctx context.Context, mbid string, filter filters.RecordingCount) (int, error) { 114 97 var statement = /*sql*/ ` 115 - SELECT COUNT(*) FROM scrobble as scrobbles 98 + SELECT COUNT(*) FROM scrobble 116 99 WHERE %s 117 100 ` 118 - args := []any{mbid} 119 - wheres := []string{"mbid = ?"} 120 101 121 - wheres, args = recordingScrobbleCountWhere(filter, wheres, args) 102 + // length is always one since Ok is always true for mbid condition 103 + wheres, args := database.Where(make([]string, 0), make([]any, 0), 104 + database.Condition{SQL: "scrobble.mbid = ?", Ok: true, Value: mbid}, 105 + database.Condition{SQL: "scrobble.played_at <= ?", Ok: !filter.To.IsZero(), Value: filter.To}, 106 + database.Condition{SQL: "scrobble.played_at >= ?", Ok: !filter.From.IsZero(), Value: filter.From}, 107 + database.Condition{SQL: "scrobble.user_id = ?", Ok: filter.UserID != 0, Value: filter.UserID}, 108 + ) 122 109 123 110 if len(wheres) > 0 { 124 - statement = fmt.Sprintf(statement, strings.Join(wheres, " AND ")) 111 + statement = fmt.Sprintf(statement, database.WhereSQL(wheres)) 125 112 } 126 113 127 114 return database.QueryOne(ctx, repo.db, statement, args, func(r *sql.Rows) (int, error) {
+85
internal/repo/db/release.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "encoding/json" 7 + "fmt" 8 + 9 + "github.com/oscar345/keeptrack/internal/models" 10 + "github.com/oscar345/keeptrack/internal/repo" 11 + "github.com/oscar345/keeptrack/pkg/database" 12 + "github.com/oscar345/keeptrack/pkg/enum" 13 + ) 14 + 15 + type ReleaseRepoDB struct { 16 + db *sql.DB 17 + } 18 + 19 + func NewReleaseRepoDB(db *sql.DB) *ReleaseRepoDB { 20 + return &ReleaseRepoDB{db: db} 21 + } 22 + 23 + var _ repo.ReleaseRepo = (*ReleaseRepoDB)(nil) 24 + 25 + func (repo *ReleaseRepoDB) ListByIDs(ctx context.Context, mbids []string) (map[string]models.Release, error) { 26 + var statement = /*sql*/ ` 27 + SELECT 28 + release_group.gid, 29 + release_group.name, 30 + (%s) as artists 31 + FROM release_group 32 + WHERE release_group.gid IN (%s); 33 + ` 34 + statement = fmt.Sprintf( 35 + statement, 36 + artistCreditNamesJsonStatement("release_group.artist_credit"), 37 + database.Placeholders(len(mbids)), 38 + ) 39 + 40 + args := database.AppendMany(make([]any, 0), mbids) 41 + 42 + items, err := database.QueryMany(ctx, repo.db, statement, args, func(r *sql.Rows) (models.Release, error) { 43 + var release models.Release 44 + var artistsJSON string 45 + 46 + if err := r.Scan(&release.MBID, &release.Name, &artistsJSON); err != nil { 47 + return models.Release{}, err 48 + } 49 + 50 + var artists []artistCreditName 51 + if err := json.Unmarshal([]byte(artistsJSON), &artists); err != nil { 52 + return models.Release{}, err 53 + } 54 + 55 + release.Artists = enum.Map(artists, func(acn artistCreditName) models.ArtistCreditName { 56 + return acn.toModel() 57 + }) 58 + 59 + return release, nil 60 + }) 61 + 62 + if err != nil { 63 + return nil, err 64 + } 65 + 66 + ordered := enum.OrderByKeys(items, mbids, func(item models.Release) string { return item.MBID }) 67 + return enum.ZipMap(mbids, ordered), nil 68 + } 69 + 70 + func (repo *ReleaseRepoDB) GetByID(ctx context.Context, mbid string) (models.Release, error) { 71 + var statement = /*sql*/ ` 72 + SELECT release_group.name, release_group.mbid, (%s) as artists 73 + FROM release_group 74 + WHERE release_group.mbid = ?; 75 + ` 76 + statement = fmt.Sprintf(statement, artistCreditNamesJsonStatement("release_group.artist_credit")) 77 + 78 + return database.QueryOne(ctx, repo.db, statement, []any{mbid}, func(r *sql.Rows) (models.Release, error) { 79 + var model models.Release 80 + if err := r.Scan(&model.Name, &model.MBID); err != nil { 81 + return model, err 82 + } 83 + return model, nil 84 + }) 85 + }
+123
internal/repo/db/release_scrobble.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + 8 + "github.com/oscar345/keeptrack/internal/filters" 9 + "github.com/oscar345/keeptrack/internal/models" 10 + "github.com/oscar345/keeptrack/internal/repo" 11 + "github.com/oscar345/keeptrack/pkg/database" 12 + "github.com/oscar345/keeptrack/pkg/pagination" 13 + ) 14 + 15 + type ReleaseScrobbleRepoDB struct { 16 + db *sql.DB 17 + } 18 + 19 + func NewReleaseScrobbleRepoDB(db *sql.DB) *ReleaseScrobbleRepoDB { 20 + return &ReleaseScrobbleRepoDB{db: db} 21 + } 22 + 23 + var _ repo.ReleaseScrobbleRepo = (*ReleaseScrobbleRepoDB)(nil) 24 + 25 + func (repo *ReleaseScrobbleRepoDB) ListCount(ctx context.Context, filter filters.ReleaseCount) ([]models.CountMBID, pagination.Page, error) { 26 + var args = make([]any, 0) 27 + 28 + var source string 29 + switch { 30 + case filter.ArtistMBID != "": 31 + source = /*sql*/ `( 32 + SELECT scrobble.* 33 + FROM scrobble 34 + INNER JOIN recording_mbid__artist_mbid AS ra 35 + ON ra.recording_mbid = scrobble.recording_mbid 36 + WHERE ra.artist_mbid = ? 37 + )` 38 + args = append(args, filter.ArtistMBID) 39 + default: 40 + source = /*sql*/ `(SELECT scrobble.* FROM scrobble)` 41 + } 42 + 43 + var statement = /*sql*/ ` 44 + WITH source as %s, 45 + scrobbles AS ( 46 + SELECT 47 + rrg.release_group_mbid AS mbid, 48 + count(source) AS amount 49 + FROM source 50 + INNER JOIN recording_mbid__release_group_mbid as rrg 51 + ON rrg.recording_mbid = source.recording_mbid 52 + WHERE %s 53 + GROUP BY rrg.release_group_mbid 54 + ) 55 + SELECT 56 + scrobbles.mbid, 57 + scrobbles.amount, 58 + COUNT(*) OVER () AS total 59 + FROM scrobbles 60 + ORDER BY amount DESC 61 + OFFSET ? 62 + LIMIT ?; 63 + ` 64 + 65 + wheres, args := database.Where(make([]string, 0), args, 66 + database.Condition{SQL: "scrobbles.played_at <= ?", Ok: !filter.To.IsZero(), Value: filter.To}, 67 + database.Condition{SQL: "scrobbles.played_at >= ?", Ok: !filter.From.IsZero(), Value: filter.From}, 68 + database.Condition{SQL: "scrobbles.user_id = ?", Ok: filter.UserID != 0, Value: filter.UserID}, 69 + ) 70 + args = append(args, filter.Pagination.Offset(), filter.Pagination.Limit()) 71 + 72 + statement = fmt.Sprintf(statement, source, database.WhereSQL(wheres)) 73 + fmt.Println(statement) 74 + 75 + var total int 76 + items, err := database.QueryMany(ctx, repo.db, statement, args, func(r *sql.Rows) (models.CountMBID, error) { 77 + var model models.CountMBID 78 + if err := r.Scan(&model.MBID, &model.Count, &total); err != nil { 79 + return model, err 80 + } 81 + return model, nil 82 + }) 83 + 84 + if err != nil { 85 + return nil, pagination.Page{}, err 86 + } 87 + 88 + fmt.Println(items) 89 + return items, pagination.New(filter.Pagination, total), nil 90 + } 91 + 92 + func (repo *ReleaseScrobbleRepoDB) GetCount(ctx context.Context, mbid string, filter filters.ReleaseCount) (int, error) { 93 + var statement = /*sql*/ ` 94 + WITH scrobbles AS ( 95 + SELECT 96 + count(scrobble) AS amount 97 + FROM scrobble 98 + INNER JOIN recording_mbid__release_group_mbid as rrg 99 + ON rrg.recording_mbid = scrobble.recording_mbid 100 + WHERE rrg.artist_mbid = ? AND %s 101 + GROUP BY rrg.artist_mbid 102 + ) 103 + SELECT COALESCE(sum(scrobbles.amount), 0) AS amount 104 + FROM scrobbles 105 + ` 106 + 107 + args := []any{mbid} 108 + wheres, args := database.Where(make([]string, 0), args, 109 + database.Condition{SQL: "scrobble.user_id = ?", Value: filter.UserID, Ok: filter.UserID != 0}, 110 + database.Condition{SQL: "scrobble.played_at >= ?", Value: filter.From, Ok: !filter.From.IsZero()}, 111 + database.Condition{SQL: "scrobble.played_at <= ?", Value: filter.To, Ok: !filter.To.IsZero()}, 112 + ) 113 + 114 + statement = fmt.Sprintf(statement, database.WhereSQL(wheres)) 115 + 116 + return database.QueryOne(ctx, repo.db, statement, args, func(r *sql.Rows) (int, error) { 117 + var amount int 118 + if err := r.Scan(&amount); err != nil { 119 + return 0, err 120 + } 121 + return amount, nil 122 + }) 123 + }
+10
internal/repo/repo.go
··· 24 24 List(ctx context.Context, userID string, filter pagination.Filter) ([]models.Artist, pagination.Page, error) 25 25 } 26 26 27 + type ReleaseRepo interface { 28 + ListByIDs(ctx context.Context, mbids []string) (map[string]models.Release, error) 29 + GetByID(ctx context.Context, mbid string) (models.Release, error) 30 + } 31 + 32 + type ReleaseScrobbleRepo interface { 33 + ListCount(ctx context.Context, filter filters.ReleaseCount) ([]models.CountMBID, pagination.Page, error) 34 + GetCount(ctx context.Context, mbid string, filter filters.ReleaseCount) (int, error) 35 + } 36 + 27 37 type RecordingRepo interface { 28 38 ListByIDs(ctx context.Context, mbids []string) (map[string]models.Recording, error) 29 39 GetByID(ctx context.Context, mbid string) (models.Recording, error)
+4 -1
internal/server/server.go
··· 50 50 services := s.services(generalDB, statisticsDB) 51 51 52 52 router := router. 53 - New(services.Artist, services.User, services.Recording, s.config). 53 + New(services.Artist, services.User, services.Recording, services.Release, s.config). 54 54 Router() 55 55 56 56 log.Printf("Listening on http://%s\n", s.address) ··· 78 78 recordingLikeRepo := db.NewRecordingLikeRepo(generalDB) 79 79 recordingScrobbleRepo := db.NewRecordingScrobbleRepoDB(statisticsDB) 80 80 81 + releaseRepo := db.NewReleaseRepoDB(generalDB) 82 + releaseScrobbleRepo := db.NewReleaseScrobbleRepoDB(statisticsDB) 81 83 releaseImageFetcher := image.NewReleaseImageFetcherCoverArtArchive() 82 84 83 85 storage := storagesvc.NewDiskStorage("/public", s.config.Storage.Disk.Path) ··· 97 99 mediaProvider, 98 100 ), 99 101 Recording: services.NewRecordingService(recordingRepo, recordingLikeRepo, recordingScrobbleRepo), 102 + Release: services.NewReleaseService(releaseRepo, releaseScrobbleRepo), 100 103 } 101 104 }
+49
internal/services/release.go
··· 1 1 package services 2 2 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/oscar345/keeptrack/internal/filters" 8 + "github.com/oscar345/keeptrack/internal/models" 9 + "github.com/oscar345/keeptrack/internal/repo" 10 + "github.com/oscar345/keeptrack/pkg/enum" 11 + "github.com/oscar345/keeptrack/pkg/pagination" 12 + ) 13 + 3 14 type ReleaseRepos struct { 15 + release repo.ReleaseRepo 16 + scrobble repo.ReleaseScrobbleRepo 4 17 } 5 18 6 19 type ReleaseService struct { 20 + repos ReleaseRepos 21 + } 22 + 23 + func NewReleaseService(release repo.ReleaseRepo, scrobble repo.ReleaseScrobbleRepo) ReleaseService { 24 + return ReleaseService{ 25 + repos: ReleaseRepos{ 26 + release: release, 27 + scrobble: scrobble, 28 + }, 29 + } 30 + } 31 + 32 + func (rs *ReleaseService) ListByCount(ctx context.Context, filter filters.ReleaseCount) ([]models.Release, pagination.Page, error) { 33 + filter.UserID = 0 34 + 35 + counts, page, err := rs.repos.scrobble.ListCount(ctx, filter) 36 + if err != nil { 37 + return nil, page, err 38 + } 39 + 40 + fmt.Println("counts from 1", counts) 41 + releases, err := rs.repos.release.ListByIDs(ctx, enum.Map(counts, func(item models.CountMBID) string { 42 + return item.MBID 43 + })) 44 + fmt.Println("releases from 1", releases) 45 + if err != nil { 46 + return nil, page, err 47 + } 48 + 49 + result := enum.Map(counts, func(item models.CountMBID) models.Release { 50 + release := releases[item.MBID] 51 + release.Count = item.Count 52 + return release 53 + }) 54 + 55 + return result, page, err 7 56 }
+28 -7
internal/web/requests/catalog.go
··· 13 13 } 14 14 15 15 type Count struct { 16 - Pagination 17 - From time.Time `json:"from"` 18 - To time.Time `json:"to"` 16 + Pagination Pagination `json:"pagination"` 17 + From time.Time `json:"from"` 18 + To time.Time `json:"to"` 19 19 } 20 20 21 21 func (req Count) Filter() (filters.ArtistCount, error) { ··· 32 32 } 33 33 34 34 type RecordingCount struct { 35 - Pagination 36 - From time.Time `json:"from"` 37 - To time.Time `json:"to"` 38 - ArtistMBID string `json:"artist_mbid"` 35 + Pagination Pagination `json:"pagination"` 36 + From time.Time `json:"from"` 37 + To time.Time `json:"to"` 38 + ArtistMBID string `json:"artist_mbid"` 39 39 } 40 40 41 41 func (req RecordingCount) Filter() (filters.RecordingCount, error) { ··· 51 51 52 52 return filter, nil 53 53 } 54 + 55 + type ReleaseCount struct { 56 + Pagination Pagination `json:"pagination"` 57 + From time.Time `json:"from"` 58 + To time.Time `json:"to"` 59 + ArtistMBID string `json:"artist_mbid"` 60 + } 61 + 62 + func (req ReleaseCount) Filter() (filters.ReleaseCount, error) { 63 + filter := filters.ReleaseCount{ 64 + Pagination: pagination.Filter{ 65 + Page: req.Pagination.Page, 66 + Size: req.Pagination.Size, 67 + }, 68 + From: req.From, 69 + To: req.To, 70 + ArtistMBID: req.ArtistMBID, 71 + } 72 + 73 + return filter, nil 74 + }
+15 -5
internal/web/responses/catalog.go
··· 1 1 package responses 2 2 3 3 import ( 4 + "fmt" 5 + 4 6 "github.com/oscar345/keeptrack/internal/models" 5 7 "github.com/oscar345/keeptrack/pkg/enum" 6 8 "github.com/oscar345/keeptrack/pkg/pagination" ··· 55 57 } 56 58 57 59 type Release struct { 58 - MBID string `json:"mbid"` 59 - Name string `json:"name"` 60 + MBID string `json:"mbid"` 61 + Name string `json:"name"` 62 + Artists []ArtistCreditName `json:"artists"` 63 + Count int `json:"count"` 60 64 } 61 65 62 66 func NewReleaseFromModel(model models.Release) Release { 63 - return Release{ 64 - MBID: model.MBID, 65 - Name: model.Name, 67 + release := Release{ 68 + MBID: model.MBID, 69 + Name: model.Name, 70 + Artists: enum.Map(model.Artists, NewArtistCreditNameFromModel), 71 + Count: model.Count, 66 72 } 73 + 74 + fmt.Println(release) 75 + 76 + return release 67 77 } 68 78 69 79 type Page[T any] struct {
+30 -2
internal/web/router/router.go
··· 26 26 artist services.ArtistService 27 27 user services.UserService 28 28 recording services.RecordingService 29 + release services.ReleaseService 29 30 } 30 31 31 32 type Server struct { ··· 37 38 artistService services.ArtistService, 38 39 userService services.UserService, 39 40 recordingService services.RecordingService, 41 + releaseService services.ReleaseService, 40 42 config *config.Config, 41 43 ) *Server { 42 44 return &Server{ ··· 44 46 artist: artistService, 45 47 user: userService, 46 48 recording: recordingService, 49 + release: releaseService, 47 50 }, 48 51 config: config, 49 52 } ··· 73 76 r.Group(s.index()) 74 77 r.Route("/artists", s.artists()) 75 78 r.Route("/recordings", s.recordings()) 79 + r.Route("/releases", s.releases()) 76 80 77 81 return r 78 82 } ··· 128 132 } 129 133 } 130 134 131 - func (*Server) releases() func(chi.Router) { 135 + func (s *Server) releases() func(chi.Router) { 132 136 return func(r chi.Router) { 133 - r.Get("/", func(w http.ResponseWriter, r *http.Request) {}) 137 + r.Get("/", func(w http.ResponseWriter, r *http.Request) { 138 + var request requests.ReleaseCount 139 + query := r.URL.Query().Get("query") 140 + 141 + if query != "" { 142 + if err := json.Unmarshal([]byte(query), &request); err != nil { 143 + http.Error(w, err.Error(), http.StatusBadRequest) 144 + return 145 + } 146 + } 147 + 148 + filter, _ := request.Filter() 149 + 150 + items, page, err := s.services.release.ListByCount(r.Context(), filter) 151 + if err != nil { 152 + http.Error(w, err.Error(), http.StatusInternalServerError) 153 + return 154 + } 155 + 156 + fmt.Println("items", items) 157 + 158 + render.JSON(w, r, responses.Paginate( 159 + page, enum.Map(items, responses.NewReleaseFromModel), 160 + )) 161 + }) 134 162 } 135 163 } 136 164
+34 -18
pkg/database/query.go
··· 6 6 "strings" 7 7 ) 8 8 9 - // Expands inner arrays in the slice of args, so it can be used in a query with variadic arguments. 10 - func ExpandArgs(args []any) []any { 11 - out := make([]any, 0) 12 - for _, arg := range args { 13 - switch v := arg.(type) { 14 - case []string: 15 - for _, s := range v { 16 - out = append(out, s) 17 - } 18 - case []int: 19 - for _, i := range v { 20 - out = append(out, i) 21 - } 22 - default: 23 - out = append(out, arg) 24 - } 9 + func AppendMany[T any](args []any, values []T) []any { 10 + for _, value := range values { 11 + args = append(args, value) 25 12 } 26 - return out 13 + return args 27 14 } 28 15 29 16 // QueryMany executes a query and returns a slice of results. It is a small helper function reducing ··· 70 57 71 58 // Generates a string of placeholders for a SQL query. For example, if count is 3, the result will 72 59 // be "?, ?, ?". 73 - func GeneratePlaceholders(count int) string { 60 + func Placeholders(count int) string { 74 61 placeholders := make([]string, count) 75 62 for i := range placeholders { 76 63 placeholders[i] = "?" 77 64 } 78 65 return strings.Join(placeholders, ", ") 79 66 } 67 + 68 + type Condition struct { 69 + SQL string 70 + Value any 71 + Ok bool 72 + } 73 + 74 + // Takes a list of where strings and argument values together with a list of conditions. Those 75 + // conditions that are Ok will be added to the list of where strings and argument values. Useful 76 + // when having optional conditionals that used to filter a query. 77 + func Where(wheres []string, args []any, conditions ...Condition) ([]string, []any) { 78 + for _, v := range conditions { 79 + if v.Ok { 80 + wheres = append(wheres, v.SQL) 81 + args = append(args, v.Value) 82 + } 83 + } 84 + return wheres, args 85 + } 86 + 87 + // Takes a list of SQL condition snippets and joins them with AND. If the list is empty, returns 88 + // TRUE. 89 + func WhereSQL(wheres []string) string { 90 + where := "TRUE" 91 + if len(wheres) > 0 { 92 + where = strings.Join(wheres, " AND ") 93 + } 94 + return where 95 + }
+2 -2
scripts/seeds/statistics.py
··· 57 57 mb_conn: sqlite3.Connection, stats_conn: duckdb.DuckDBPyConnection 58 58 ): 59 59 for df_chunk in pl.read_database( 60 - query_release, connection=mb_conn, batch_size=100000, iter_batches=True 60 + query_release_group, connection=mb_conn, batch_size=100000, iter_batches=True 61 61 ): 62 62 stats_conn.register("chunk", df_chunk) 63 63 stats_conn.execute(""" ··· 70 70 stats_conn = duckdb.connect(STATS_DATABASE_PATH) 71 71 mb_conn = sqlite3.connect(MB_DATABASE_PATH) 72 72 73 - artist_mbid__recording__mbid(mb_conn, stats_conn) 73 + # artist_mbid__recording__mbid(mb_conn, stats_conn) 74 74 release_group_mbid__recording__mbid(mb_conn, stats_conn) 75 75 76 76 stats_conn.close()