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.

Get data from the api into the sveltekit application for the artist page. Scrobbles counts, artist name and artist image are loaded.

oscar345 d8c44110 78f1efb5

+660 -110
+3 -3
TODOS.md
··· 12 12 13 13 - [x] authentication 14 14 - [x] validation 15 - - [ ] settings section 16 - - [ ] services page in settings 17 - - [ ] periodically sync spotify listening history with keeptrack 15 + - [x] settings section 16 + - [x] services page in settings 17 + - [ ] (periodically sync spotify listening history with keeptrack (maybe let this be done)) 18 18 - [ ] a general artists page to view recordings and releases from an artist 19 19 - [ ] a general releases page to view recordings, and release versions of a release 20 20 - [ ] a general recordings page to view releases it appears on, and the artists that are credited
+16
client/src/app.css
··· 61 61 --spacing-80: 20rem; 62 62 --spacing-96: 24rem; 63 63 64 + --spacing-main-padding: var(--spacing-6); 65 + --spacing-main-gap-y: var(--spacing-6); 66 + --spacing-main-gap-x: var(--spacing-6); 67 + 68 + --spacing-form-gap-y: var(--spacing-3); 69 + --spacing-form-gap-x: var(--spacing-2); 70 + 71 + --spacing-section-gap-y: var(--spacing-2); 72 + --spacing-section-gap-x: var(--spacing-2); 73 + 74 + --spacing-nav-padding: var(--spacing-4); 75 + 76 + --spacing-article-padding: var(--spacing-4); 77 + --spacing-article-gap-y: var(--spacing-4); 78 + --spacing-article-gap-x: var(--spacing-4); 79 + 64 80 /* Widths */ 65 81 --width-3xs: 16rem; 66 82 --width-2xs: 18rem;
+1
client/src/lib/components/brand/Avatar.svelte
··· 42 42 & p { 43 43 font-size: var(--text-sm); 44 44 line-height: var(--text-sm--line-height); 45 + color: var(--color-content-100); 45 46 } 46 47 47 48 & span {
+1 -1
client/src/lib/components/brand/Logo.svelte
··· 10 10 p { 11 11 color: var(--color-content-100); 12 12 text-transform: uppercase; 13 - font-weight: var(--font-weight-medium); 13 + font-weight: var(--font-weight-bold); 14 14 font-size: var(--text-sm); 15 15 } 16 16 </style>
+5 -1
client/src/lib/components/interaction/Input.svelte
··· 23 23 height: var(--spacing-10); 24 24 width: 100%; 25 25 box-shadow: var(--shadow-xs); 26 - background-color: hsl(from var(--color-base-100) h s 90%); 26 + background-color: hsl(from var(--color-base-100) h s 96%); 27 27 font-size: var(--text-base); 28 28 line-height: var(--text-base--line-height); 29 + 30 + @media (prefers-color-scheme: dark) { 31 + background-color: hsl(from var(--color-base-100) h s 12%); 32 + } 29 33 30 34 @media (width >= 40rem) { 31 35 height: var(--spacing-10);
+1 -1
client/src/lib/components/interaction/Select.svelte
··· 35 35 width: 100%; 36 36 box-shadow: var(--shadow-xs); 37 37 border-radius: var(--radius-interactive); 38 - background-color: hsl(from var(--color-base-100) h s 90%); 38 + background-color: hsl(from var(--color-base-100) h s 96%); 39 39 font-size: var(--text-base); 40 40 line-height: var(--text-base--line-height); 41 41
-32
client/src/lib/components/interaction/Setting.svelte
··· 1 - <script lang="ts"> 2 - import type { Snippet } from "svelte"; 3 - import Button from "$lib/components/interaction/Button.svelte"; 4 - 5 - type Props = { 6 - value: Snippet; 7 - save: () => boolean; 8 - field: Snippet; 9 - direction: "horizontal" | "vertical"; 10 - }; 11 - 12 - let editing = $state(false); 13 - 14 - let { value, save, field, direction = "horizontal" }: Props = $props(); 15 - </script> 16 - 17 - <div class={direction}> 18 - {#if editing} 19 - {@render field()} 20 - <Button 21 - variant="text" 22 - onclick={() => { 23 - if (save()) { 24 - editing = false; 25 - } 26 - }}>Save</Button 27 - > 28 - {:else} 29 - {@render value()} 30 - <Button variant="text" onclick={() => (editing = true)}>Edit</Button> 31 - {/if} 32 - </div>
+4 -3
client/src/lib/components/layout/app/Navigation.svelte
··· 3 3 import Avatar from "$lib/components/brand/Avatar.svelte"; 4 4 import Logo from "$lib/components/brand/Logo.svelte"; 5 5 import Icon from "$lib/components/content/Icon.svelte"; 6 + import Field from "$lib/components/interaction/Field.svelte"; 6 7 import type { NavigationItemProps } from "$lib/types"; 7 8 8 9 type Props = {}; ··· 55 56 <a href="/" class="logo"> 56 57 <Logo /> 57 58 </a> 59 + 58 60 <ul> 59 61 {#each primary as item} 60 62 <li> ··· 91 93 } 92 94 93 95 nav { 94 - height: 100%; 95 - padding-inline: var(--spacing-4); 96 + height: 100dvh; 97 + padding: var(--spacing-nav-padding); 96 98 display: grid; 97 99 row-gap: var(--spacing-4); 98 100 grid-template-areas: "logo" "primary" "." "bottom"; 99 - padding-block: var(--spacing-4); 100 101 grid-template-rows: max-content max-content 1fr max-content; 101 102 } 102 103
+1 -1
client/src/lib/index.ts
··· 6 6 7 7 export const api = ky.create({ 8 8 fetch: fetch, 9 - prefixUrl: "http://localhost:3000", 9 + prefixUrl: "http://127.0.0.1:3000", 10 10 hooks: { 11 11 beforeRequest: [ 12 12 async (request, options) => {
+8 -3
client/src/routes/app/+layout.svelte
··· 10 10 let { children }: Props = $props(); 11 11 </script> 12 12 13 - <Navigation /> 13 + <div class="navigation"> 14 + <Navigation /> 15 + </div> 14 16 <div class="content"> 15 17 {@render children()} 16 18 </div> ··· 19 21 :global(body) { 20 22 display: grid; 21 23 grid-template-rows: 1fr; 22 - grid-template-columns: var(--width-2xs) 3fr; 24 + grid-template-columns: var(--width-3xs) 3fr; 23 25 min-height: 100dvh; 24 26 grid-template-areas: "navigation content"; 25 27 row-gap: var(--spacing-6); ··· 31 33 background-color: var(--color-base-300); 32 34 } 33 35 34 - :global(body > nav) { 36 + .navigation { 35 37 grid-area: navigation; 38 + height: 100dvh; 39 + position: sticky; 40 + top: 0; 36 41 } 37 42 </style>
+42
client/src/routes/app/catalog/artists/[id]/+page.server.ts
··· 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"; 5 + import type { PageServerLoad } from "./$types"; 6 + 7 + export const load: PageServerLoad = async ({ params }) => { 8 + const artist = await call(api, GET_ArtistsByID(params.id), {}).json<Artist>(); 9 + const totalCount = call(api, GET_ArtistsByIDCount(params.id), {}).json<Count>(); 10 + 11 + const lastSevenDays = call(api, GET_ArtistsByIDCount(params.id), { 12 + searchParams: { 13 + query: JSON.stringify({ 14 + from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), 15 + } as CountRequest), 16 + }, 17 + }).json<Count>(); 18 + 19 + const lastThirtyDays = call(api, GET_ArtistsByIDCount(params.id), { 20 + searchParams: { 21 + query: JSON.stringify({ 22 + from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), 23 + } as CountRequest), 24 + }, 25 + }).json<Count>(); 26 + 27 + const lastYear = call(api, GET_ArtistsByIDCount(params.id), { 28 + searchParams: { 29 + query: JSON.stringify({ 30 + from: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString(), 31 + } as CountRequest), 32 + }, 33 + }).json<Count>(); 34 + 35 + return { 36 + artist, 37 + totalCount, 38 + lastSevenDays, 39 + lastThirtyDays, 40 + lastYear, 41 + }; 42 + };
+96
client/src/routes/app/catalog/artists/[id]/+page.svelte
··· 1 + <script lang="ts"> 2 + import type { PageProps } from "./$types"; 3 + 4 + let { data, form }: PageProps = $props(); 5 + </script> 6 + 7 + <main class="@container"> 8 + <header class="header"> 9 + <hgroup> 10 + <h1 class="h1">{data.artist.name}</h1> 11 + <p class="subtitle"> 12 + Lorem ipsum dolor sit amet consectetur adipisicing elit. Harum 13 + veritatis dolor accusantium optio reprehenderit alias nesciunt 14 + magnam nihil, mollitia tempore, rem tenetur illo expedita eum, 15 + possimus asperiores cum iste et! 16 + </p> 17 + </hgroup> 18 + </header> 19 + <figure> 20 + <img src="http://localhost:3000{data.artist.image_url}" alt="" /> 21 + </figure> 22 + <section class="statistics"> 23 + <article> 24 + {#await data.totalCount} 25 + Loading... 26 + {:then count} 27 + <p class="h1">{count.value}</p> 28 + {/await} 29 + 30 + <p class="label">Total plays of this artist by all users</p> 31 + </article> 32 + <article> 33 + {#await data.lastSevenDays} 34 + Loading... 35 + {:then count} 36 + <p class="h1">{count.value}</p> 37 + {/await} 38 + 39 + <p class="label">Plays of all users in the last 7 days</p> 40 + </article> 41 + <article> 42 + {#await data.lastThirtyDays} 43 + Loading... 44 + {:then count} 45 + <p class="h1">{count.value}</p> 46 + {/await} 47 + 48 + <p class="label">Plays of all users in the last 30 days</p> 49 + </article> 50 + <article> 51 + {#await data.lastYear} 52 + Loading... 53 + {:then count} 54 + <p class="h1">{count.value}</p> 55 + {/await} 56 + 57 + <p class="label">Plays of all users in the last year</p> 58 + </article> 59 + </section> 60 + </main> 61 + 62 + <style> 63 + .statistics { 64 + display: grid; 65 + grid-template-columns: repeat(2, 1fr); 66 + gap: var(--spacing-section-gap-y); 67 + 68 + @container (width >= 48rem) { 69 + grid-template-columns: repeat(4, 1fr); 70 + } 71 + } 72 + 73 + article { 74 + border: var(--theme-default-border); 75 + padding: var(--spacing-article-padding); 76 + display: flex; 77 + flex-direction: column; 78 + justify-content: space-between; 79 + height: var(--spacing-28); 80 + } 81 + 82 + main { 83 + padding: var(--spacing-main-padding); 84 + display: flex; 85 + flex-direction: column; 86 + gap: var(--spacing-main-gap-y); 87 + } 88 + 89 + figure > img { 90 + height: var(--width-xs); 91 + width: 100%; 92 + border: var(--theme-default-border); 93 + object-fit: cover; 94 + object-position: 50% 12.5%; 95 + } 96 + </style>
+19
client/src/routes/app/dashboard/+page.svelte
··· 1 + <script> 2 + import Link from "$lib/components/interaction/Link.svelte"; 3 + </script> 4 + 1 5 <main> 2 6 <header class="header"> 3 7 <hgroup> ··· 9 13 </p> 10 14 </hgroup> 11 15 </header> 16 + 17 + <section> 18 + <header class="header"> 19 + <hgroup> 20 + <h2 class="h2">Artists</h2> 21 + </hgroup> 22 + </header> 23 + 24 + <Link href="/app/catalog/artists/b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d"> 25 + the beatles 26 + </Link> 27 + <Link href="/app/catalog/artists/a74b1b7f-71a5-4011-9441-d0b5e4122711"> 28 + radiohead 29 + </Link> 30 + </section> 12 31 </main> 13 32 14 33 <style>
+17
client/src/routes/app/friends/+page.svelte
··· 1 + <main> 2 + <header class="header"> 3 + <hgroup> 4 + <h1 class="h1">Friends</h1> 5 + <p class="subtitle"> 6 + Lorem ipsum dolor sit amet consectetur adipisicing elit. Doloremque 7 + repellat soluta illo rerum non aliquam veritatis. 8 + </p> 9 + </hgroup> 10 + </header> 11 + </main> 12 + 13 + <style> 14 + main { 15 + padding: var(--spacing-6); 16 + } 17 + </style>
+13
client/src/routes/app/library/+page.svelte
··· 1 + <main> 2 + <header class="header"> 3 + <hgroup> 4 + <h1 class="h1">Library</h1> 5 + </hgroup> 6 + </header> 7 + </main> 8 + 9 + <style> 10 + main { 11 + padding: var(--spacing-main-padding); 12 + } 13 + </style>
+146 -3
client/src/routes/app/settings/+page.svelte
··· 1 1 <script lang="ts"> 2 + import Icon from "$lib/components/content/Icon.svelte"; 3 + import Button from "$lib/components/interaction/Button.svelte"; 4 + import Field from "$lib/components/interaction/Field.svelte"; 5 + import Link from "$lib/components/interaction/Link.svelte"; 6 + 2 7 type Props = {}; 3 8 4 9 let {}: Props = $props(); 5 10 </script> 6 11 7 - <main> 12 + <div class="main"> 8 13 <header class="header"> 9 14 <hgroup> 10 15 <h1 class="h1">Settings</h1> ··· 16 21 </p> 17 22 </hgroup> 18 23 </header> 19 - </main> 24 + 25 + <main> 26 + <section id="theme"> 27 + <header class="header"> 28 + <hgroup> 29 + <h2 class="h2">Theme</h2> 30 + <p class="subtitle"> 31 + Lorem ipsum dolor sit amet, consectetur adipisicing elit. 32 + Quibusdam hic nisi quisquam. 33 + </p> 34 + </hgroup> 35 + </header> 36 + <div class="form group"> 37 + <Button> 38 + <Icon name="hero-server" /> save 39 + </Button><Button> 40 + <Icon name="hero-server" /> save 41 + </Button><Button> 42 + <Icon name="hero-server" /> save 43 + </Button> 44 + </div> 45 + </section> 46 + <section id="account"> 47 + <header class="header"> 48 + <hgroup> 49 + <h2 class="h2">Account</h2> 50 + <p class="subtitle"> 51 + Lorem ipsum dolor sit amet, consectetur adipisicing elit. 52 + Quibusdam hic nisi quisquam. 53 + </p> 54 + </hgroup> 55 + </header> 56 + <form action=""> 57 + <Field as="input" label="username" /> 58 + <Field as="input" label="password" /> 59 + <Field as="input" label="email" /> 60 + <Field as="input" label="phone" /> 61 + <Button scheme="primary"> 62 + <Icon name="hero-server" /> save 63 + </Button> 64 + </form> 65 + </section> 66 + 67 + <section id="services"> 68 + <header class="header"> 69 + <hgroup> 70 + <h2 class="h2">Services</h2> 71 + <p class="subtitle"> 72 + Lorem ipsum dolor sit amet consectetur adipisicing elit. 73 + Mollitia explicabo soluta atque autem fuga quos magni 74 + consectetur minima eaque laudantium. 75 + </p> 76 + </hgroup> 77 + </header> 78 + <div class="form"> 79 + <Link href="http://localhost:3000/users/settings/spotify/login" 80 + >Connect to spotify</Link 81 + > 82 + <Button>Connect to last.fm</Button> 83 + <Button>Connect to Apple Music</Button> 84 + <Button>Connect to Qobuz</Button> 85 + <Button>Connect to Deezer</Button> 86 + </div> 87 + </section> 88 + 89 + <section id="other"> 90 + <header class="header"> 91 + <hgroup> 92 + <h2 class="h2">Other settings</h2> 93 + <p class="subtitle"> 94 + Lorem ipsum dolor sit amet, consectetur adipisicing elit. 95 + Quibusdam hic nisi quisquam. 96 + </p> 97 + </hgroup> 98 + </header> 99 + <form action=""> 100 + <Field as="input" label="username" /> 101 + <Field as="input" label="password" /> 102 + <Field as="input" label="email" /> 103 + <Field as="input" label="phone" /> 104 + <Button scheme="primary"> 105 + <Icon name="hero-server" /> save 106 + </Button> 107 + </form> 108 + </section> 109 + </main> 110 + </div> 20 111 21 112 <style> 113 + .main { 114 + padding: var(--spacing-main-padding); 115 + display: grid; 116 + grid-template-columns: var(--width-3xs) 2fr; 117 + row-gap: var(--spacing-main-gap-y); 118 + column-gap: var(--spacing-main-gap-x); 119 + grid-template-areas: 120 + "header header" 121 + "main main"; 122 + } 123 + 124 + .main > header { 125 + grid-area: header; 126 + } 127 + 128 + .main > main { 129 + grid-area: main; 130 + } 131 + 22 132 main { 23 - padding: var(--spacing-6); 133 + display: flex; 134 + flex-direction: column; 135 + gap: calc(var(--spacing-main-gap-y) * 2); 136 + } 137 + 138 + section { 139 + display: grid; 140 + grid-template-columns: var(--width-xs) 2fr; 141 + grid-template-areas: "header form"; 142 + gap: var(--spacing-section-gap-y); 143 + 144 + & > header { 145 + grid-area: header; 146 + } 147 + 148 + & > :where(.form, form) { 149 + grid-area: form; 150 + } 151 + } 152 + 153 + form, 154 + .form { 155 + display: flex; 156 + flex-direction: column; 157 + gap: var(--spacing-form-gap-y); 158 + } 159 + 160 + .form.group { 161 + flex-direction: row; 162 + width: 100%; 163 + 164 + & > :global(*) { 165 + width: 100%; 166 + } 24 167 } 25 168 </style>
+5 -1
client/src/styles/button.css
··· 9 9 10 10 .button.variant-default { 11 11 gap: var(--spacing-1); 12 - line-height: var(--text-sm--line-height); 12 + line-height: var(--leading-tighter); 13 13 height: var(--spacing-10); 14 14 15 15 padding-inline: var(--padding-inline); ··· 80 80 --bg-color: var(--color-primary); 81 81 --text-color: var(--color-primary-contrast); 82 82 --border-color: hsl(from var(--bg-color) h s calc(l * 0.25) / 1); 83 + 84 + @media (prefers-color-scheme: dark) { 85 + --border-color: hsl(from var(--text-color) h s 80%); 86 + } 83 87 }
+6 -4
client/src/styles/colors.css
··· 37 37 --theme-default-border: 1px solid var(--color-muted-200); 38 38 } 39 39 40 - /*@media (prefers-color-scheme: dark) { 40 + @media (prefers-color-scheme: dark) { 41 41 :root { 42 42 --mixin-color-light-100: #fff 20%; 43 43 --mixin-color-light-200: #fff 50%; ··· 65 65 66 66 --theme-color-primary: var(--color-blue-600); 67 67 --theme-color-primary-contrast: var(--color-blue-50); 68 - --theme-color-secondary: var(--color-blue-200); 69 - --theme-color-secondary-contrast: var(--color-blue-950); 68 + --theme-color-secondary: hsl(from var(--color-blue-700) h 48% 16%); 69 + --theme-color-secondary-contrast: hsl( 70 + from var(--color-blue-500) h 80% 88% 71 + ); 70 72 --theme-color-success: #00cc66; 71 73 --theme-color-success-contrast: #fff; 72 74 --theme-color-warning: #ff9900; ··· 76 78 77 79 --theme-default-border: 1px solid var(--color-muted-200); 78 80 } 79 - }*/ 81 + } 80 82 }
+1
go.mod
··· 41 41 github.com/zeebo/xxh3 v1.0.2 // indirect 42 42 golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect 43 43 golang.org/x/mod v0.31.0 // indirect 44 + golang.org/x/oauth2 v0.34.0 44 45 golang.org/x/sys v0.40.0 // indirect 45 46 golang.org/x/telemetry v0.0.0-20251208220230-2638a1023523 // indirect 46 47 golang.org/x/time v0.9.0 // indirect
+2
go.sum
··· 86 86 golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= 87 87 golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= 88 88 golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= 89 + golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= 90 + golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 89 91 golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 90 92 golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 91 93 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+7
internal/models/user.go
··· 6 6 Password string 7 7 Email string 8 8 } 9 + 10 + type UserService struct { 11 + Service string 12 + UserID int 13 + Token string 14 + RefreshToken string 15 + }
+1 -1
internal/repo/db/artist.go
··· 48 48 49 49 func (repo *ArtistRepoDB) GetByID(ctx context.Context, mbid string) (models.Artist, error) { 50 50 const statement = /*sql*/ ` 51 - SELECT artist.mbid, artist.name FROM artist WHERE artist.mbid = ?; 51 + SELECT artist.gid, artist.name FROM artist WHERE artist.gid = ?; 52 52 ` 53 53 54 54 return database.QueryOne(ctx, repo.db, statement, []any{mbid}, func(r *sql.Rows) (models.Artist, error) {
+6 -4
internal/repo/db/artist_scrobble.go
··· 4 4 "context" 5 5 "database/sql" 6 6 "fmt" 7 + "log" 7 8 "strings" 8 9 9 10 "github.com/oscar345/keeptrack/internal/filters" ··· 31 32 32 33 if !filter.From.IsZero() { 33 34 wheres = append(wheres, "scrobble.played_at >= ?") 34 - args = append(args, filter.From.UnixMicro()) 35 + args = append(args, filter.From) 35 36 } 36 37 37 38 if !filter.To.IsZero() { 38 39 wheres = append(wheres, "scrobble.played_at <= ?") 39 - args = append(args, filter.To.UnixMicro()) 40 + args = append(args, filter.To) 40 41 } 41 42 42 43 return wheres, args ··· 97 98 var statement = /*sql*/ ` 98 99 WITH scrobbles AS ( 99 100 SELECT 100 - recording_mbid__artist_mbid.artist_mbid AS mbid, 101 101 count(scrobble) AS amount 102 102 FROM scrobble 103 103 INNER JOIN recording_mbid__artist_mbid ··· 105 105 WHERE recording_mbid__artist_mbid.artist_mbid = ? AND %s 106 106 GROUP BY recording_mbid__artist_mbid.artist_mbid 107 107 ) 108 - SELECT sum(scrobbles.amount) AS amount 108 + SELECT COALESCE(sum(scrobbles.amount), 0) AS amount 109 109 FROM scrobbles 110 110 ` 111 111 ··· 120 120 } 121 121 122 122 statement = fmt.Sprintf(statement, where) 123 + 124 + log.Printf("statement: %s", statement) 123 125 124 126 return database.QueryOne(ctx, repo.duck, statement, args, func(r *sql.Rows) (int, error) { 125 127 var amount int
+26
internal/repo/db/user_service.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + 7 + "github.com/oscar345/keeptrack/internal/models" 8 + "github.com/oscar345/keeptrack/internal/repo" 9 + ) 10 + 11 + type UserServiceRepoDB struct { 12 + db *sql.DB 13 + } 14 + 15 + var _ repo.UserServiceRepo = (*UserServiceRepoDB)(nil) 16 + 17 + func NewUserServiceRepoDB(db *sql.DB) *UserServiceRepoDB { 18 + return &UserServiceRepoDB{db: db} 19 + } 20 + 21 + func (repo *UserServiceRepoDB) Create(ctx context.Context, service models.UserService) (int, error) { 22 + return 0, nil 23 + } 24 + func (repo *UserServiceRepoDB) GetByServiceAndUserID(ctx context.Context, service string, userID string) (models.UserService, error) { 25 + return models.UserService{}, nil 26 + }
+5
internal/repo/repo.go
··· 46 46 GetByEmail(ctx context.Context, email string) (models.User, error) 47 47 } 48 48 49 + type UserServiceRepo interface { 50 + Create(ctx context.Context, service models.UserService) (int, error) 51 + GetByServiceAndUserID(ctx context.Context, service string, userID string) (models.UserService, error) 52 + } 53 + 49 54 type UserFollowRepo interface { 50 55 Follow(ctx context.Context, userID int, targetID int) error 51 56 Unfollow(ctx context.Context, userID int, targetID int) error
+7 -1
internal/server/server.go
··· 70 70 71 71 userRepo := db.NewUserRepoDB(generalDB) 72 72 userFollowRepo := db.NewUserFollowRepoDB(generalDB) 73 + userServiceRepo := db.NewUserServiceRepoDB(generalDB) 73 74 74 75 releaseImageFetcher := image.NewReleaseImageFetcherCoverArtArchive() 75 76 ··· 82 83 artistRepo, artistScrobbleRepo, artistImageFetcher, mediaProvider, storage, 83 84 ), 84 85 User: services.NewUserService( 85 - userRepo, userFollowRepo, artistRepo, artistScrobbleRepo, mediaProvider, 86 + userRepo, 87 + userFollowRepo, 88 + userServiceRepo, 89 + artistRepo, 90 + artistScrobbleRepo, 91 + mediaProvider, 86 92 ), 87 93 } 88 94 }
+19 -1
internal/services/artist.go
··· 37 37 } 38 38 } 39 39 40 - func (as *ArtistService) ListArtistByCount(ctx context.Context, filter filters.ArtistCount) ([]models.Artist, pagination.Page, error) { 40 + func (as *ArtistService) GetByID(ctx context.Context, mbid string) (models.Artist, error) { 41 + // if there is no image because of an error, just ignore the error for now 42 + image, _ := as.mediaProvider.GetArtistImage(ctx, mbid) 43 + 44 + artist, err := as.artistRepo.GetByID(ctx, mbid) 45 + if err != nil { 46 + return models.Artist{}, err 47 + } 48 + 49 + artist.ImageURL = image 50 + 51 + return artist, nil 52 + } 53 + 54 + func (as *ArtistService) GetCountByID(ctx context.Context, mbid string, filter filters.ArtistCount) (int, error) { 55 + return as.artistScrobbleRepo.GetCount(ctx, mbid, filter) 56 + } 57 + 58 + func (as *ArtistService) ListByCount(ctx context.Context, filter filters.ArtistCount) ([]models.Artist, pagination.Page, error) { 41 59 // Since this should return a general list, we do not want to filter on user. The user service 42 60 // should be used to get the user specific list. 43 61 filter.UserID = 0
+17 -5
internal/services/recording.go
··· 1 1 package services 2 2 3 - import "github.com/oscar345/keeptrack/internal/repo" 3 + import ( 4 + "github.com/oscar345/keeptrack/internal/repo" 5 + ) 6 + 7 + type RecordingRepos struct { 8 + recording repo.RecordingRepo 9 + like repo.RecordingLikeRepo 10 + } 4 11 5 12 type RecordingService struct { 6 - recordingRepo repo.RecordingRepo 7 - recordingLikeRepo repo.RecordingLikeRepo 13 + repos RecordingRepos 8 14 } 9 15 10 16 func NewRecordingService(recordingRepo repo.RecordingRepo, recordingLikeRepo repo.RecordingLikeRepo) *RecordingService { 11 17 return &RecordingService{ 12 - recordingRepo: recordingRepo, 13 - recordingLikeRepo: recordingLikeRepo, 18 + repos: RecordingRepos{ 19 + like: recordingLikeRepo, 20 + recording: recordingRepo, 21 + }, 14 22 } 15 23 } 24 + 25 + // func (rs *RecordingService) ListByCount(ctx context.Context, filter filters.RecordingCount) error { 26 + // filter.UserID = 0 27 + // }
+7
internal/services/release.go
··· 1 + package services 2 + 3 + type ReleaseRepos struct { 4 + } 5 + 6 + type ReleaseService struct { 7 + }
+15
internal/services/user.go
··· 9 9 "github.com/oscar345/keeptrack/internal/repo" 10 10 "github.com/oscar345/keeptrack/pkg/enum" 11 11 "github.com/oscar345/keeptrack/pkg/pagination" 12 + "golang.org/x/oauth2" 12 13 ) 13 14 14 15 type UserService struct { 15 16 userRepo repo.UserRepo 16 17 userFollowRepo repo.UserFollowRepo 18 + userServiceRepo repo.UserServiceRepo 17 19 artistRepo repo.ArtistRepo 18 20 artistScrobbleRepo repo.ArtistScrobbleRepo 19 21 mediaProvider *providers.MediaProvider ··· 22 24 func NewUserService( 23 25 userRepo repo.UserRepo, 24 26 userFollowRepo repo.UserFollowRepo, 27 + userServiceRepo repo.UserServiceRepo, 25 28 artistRepo repo.ArtistRepo, 26 29 artistScrobbleRepo repo.ArtistScrobbleRepo, 27 30 mediaProvider *providers.MediaProvider, ··· 29 32 return UserService{ 30 33 userRepo: userRepo, 31 34 userFollowRepo: userFollowRepo, 35 + userServiceRepo: userServiceRepo, 32 36 artistRepo: artistRepo, 33 37 artistScrobbleRepo: artistScrobbleRepo, 34 38 mediaProvider: mediaProvider, ··· 85 89 ) ([]models.User, pagination.Page, error) { 86 90 return us.userFollowRepo.ListFollowing(ctx, userID, filter) 87 91 } 92 + 93 + func (us *UserService) CreateUserService(ctx context.Context, userID int, service string, token *oauth2.Token) error { 94 + _, err := us.userServiceRepo.Create(ctx, models.UserService{ 95 + UserID: userID, 96 + Service: service, 97 + Token: token.AccessToken, 98 + RefreshToken: token.RefreshToken, 99 + }) 100 + 101 + return err 102 + }
+2 -5
internal/web/middleware/middleware.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "log" 6 5 "net/http" 7 6 "strings" 8 7 ··· 25 24 ) 26 25 27 26 if err != nil { 28 - http.Error(w, "Invalid token", http.StatusUnauthorized) 29 - log.Println("JWT parse error:", err) 27 + next.ServeHTTP(w, r) 30 28 return 31 29 } 32 30 33 31 claims, ok := token.Claims.(jwt.MapClaims) 34 32 if !ok { 35 - http.Error(w, "Invalid claims", http.StatusUnauthorized) 36 - log.Println("Invalid claims") 33 + next.ServeHTTP(w, r) 37 34 return 38 35 } 39 36
+12 -30
internal/web/requests/catalog.go
··· 8 8 ) 9 9 10 10 type Pagination struct { 11 - Page int `json:"page" in:"query=page"` 12 - Size int `json:"size" in:"query=size"` 11 + Page int `json:"page"` 12 + Size int `json:"size"` 13 13 } 14 14 15 15 type Count struct { 16 - Pagination `json:"pagination"` 17 - From string `json:"from" in:"query=from"` 18 - To string `json:"to" in:"query=to"` 19 - } 20 - 21 - func ParseTime(value string, dest *time.Time) error { 22 - if value == "" { 23 - return nil 24 - } 25 - parsed, err := time.Parse(time.RFC3339, value) 26 - if err != nil { 27 - return err 28 - } 29 - dest = &parsed 30 - return nil 16 + Pagination 17 + From time.Time `json:"from"` 18 + To time.Time `json:"to"` 31 19 } 32 20 33 21 func (req Count) Filter() (filters.ArtistCount, error) { 34 - var filter filters.ArtistCount 35 - 36 - if err := ParseTime(req.From, &filter.From); err != nil { 37 - return filter, err 38 - } 39 - 40 - if err := ParseTime(req.To, &filter.To); err != nil { 41 - return filter, err 42 - } 43 - 44 - filter.Pagination = pagination.Filter{ 45 - Page: req.Pagination.Page, 46 - Size: req.Pagination.Size, 22 + filter := filters.ArtistCount{ 23 + Pagination: pagination.Filter{ 24 + Page: req.Pagination.Page, 25 + Size: req.Pagination.Size, 26 + }, 27 + From: req.From, 28 + To: req.To, 47 29 } 48 30 49 31 return filter, nil
+10
internal/web/responses/catalog.go
··· 74 74 Email: model.Email, 75 75 } 76 76 } 77 + 78 + type Count struct { 79 + Value int `json:"value"` 80 + } 81 + 82 + func NewCount(value int) Count { 83 + return Count{ 84 + Value: value, 85 + } 86 + }
+135 -10
internal/web/router/router.go
··· 1 1 package router 2 2 3 3 import ( 4 + "encoding/json" 5 + "fmt" 4 6 "log" 5 7 "net/http" 8 + "strings" 6 9 7 10 "github.com/MicahParks/keyfunc/v3" 8 11 "github.com/go-chi/chi/v5" ··· 12 15 "github.com/oscar345/keeptrack/internal/services" 13 16 "github.com/oscar345/keeptrack/internal/web/handlers" 14 17 "github.com/oscar345/keeptrack/internal/web/middleware" 18 + "github.com/oscar345/keeptrack/internal/web/requests" 19 + "github.com/oscar345/keeptrack/internal/web/responses" 20 + "golang.org/x/oauth2" 21 + "golang.org/x/oauth2/spotify" 15 22 ) 16 23 24 + type Services struct { 25 + artist services.ArtistService 26 + user services.UserService 27 + release services.ReleaseService 28 + } 29 + 17 30 type Server struct { 18 - artistService services.ArtistService 19 - userService services.UserService 20 - config *config.Config 31 + services Services 32 + config *config.Config 21 33 } 22 34 23 35 func New( ··· 26 38 config *config.Config, 27 39 ) *Server { 28 40 return &Server{ 29 - artistService: artistService, 30 - userService: userService, 31 - config: config, 41 + services: Services{ 42 + artist: artistService, 43 + user: userService, 44 + }, 45 + config: config, 32 46 } 33 47 } 34 48 ··· 49 63 50 64 r.Handle("/public*", http.StripPrefix("/public", http.FileServer(http.Dir("public/storage")))) 51 65 52 - r. 53 - With(middleware.RequireUser). 54 - Route("/artists", s.artists()) 66 + r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { 67 + w.WriteHeader(http.StatusNoContent) 68 + }) 69 + 70 + r.Group(s.index()) 71 + r.Route("/artists", s.artists()) 55 72 56 73 return r 57 74 } 58 75 59 76 func (s *Server) artists() func(chi.Router) { 60 - _ = handlers.NewCatalogArtistHandler(s.artistService) 77 + _ = handlers.NewCatalogArtistHandler(s.services.artist) 78 + 79 + return func(r chi.Router) { 80 + r.Get("/", func(w http.ResponseWriter, r *http.Request) { 81 + render.JSON(w, r, render.M{ 82 + "message": "Hello World", 83 + }) 84 + }) 85 + 86 + r.Get("/{id}", func(w http.ResponseWriter, r *http.Request) { 87 + id := chi.URLParam(r, "id") 88 + artist, err := s.services.artist.GetByID(r.Context(), id) 89 + if err != nil { 90 + http.Error(w, err.Error(), http.StatusInternalServerError) 91 + return 92 + } 93 + render.JSON(w, r, responses.NewArtistFromModel(artist)) 94 + }) 95 + 96 + r.Get("/{id}/count", func(w http.ResponseWriter, r *http.Request) { 97 + var request requests.Count 98 + query := r.URL.Query().Get("query") 99 + 100 + if query != "" { 101 + fmt.Println("query string: ", query) 102 + err := json. 103 + NewDecoder(strings.NewReader(query)). 104 + Decode(&request) 105 + 106 + if err != nil { 107 + 108 + http.Error(w, err.Error(), http.StatusBadRequest) 109 + return 110 + } 111 + } 112 + 113 + filter, _ := request.Filter() 114 + 115 + value, err := s.services.artist.GetCountByID(r.Context(), chi.URLParam(r, "id"), filter) 116 + if err != nil { 117 + fmt.Println("error: ", err) 118 + http.Error(w, err.Error(), http.StatusInternalServerError) 119 + return 120 + } 61 121 122 + render.JSON(w, r, responses.NewCount(value)) 123 + }) 124 + } 125 + } 126 + 127 + func (*Server) releases() func(chi.Router) { 128 + return func(r chi.Router) { 129 + r.Get("/", func(w http.ResponseWriter, r *http.Request) {}) 130 + } 131 + } 132 + 133 + func (s *Server) recordings() func (chi.Router) { 62 134 return func(r chi.Router) { 63 135 r.Get("/", func(w http.ResponseWriter, r *http.Request) { 136 + 137 + }) 138 + } 139 + } 140 + 141 + func (s *Server) index() func(chi.Router) { 142 + spotifyConfig := &oauth2.Config{ 143 + ClientID: s.config.Services.Spotify.ClientID, 144 + ClientSecret: s.config.Services.Spotify.ClientSecret, 145 + RedirectURL: s.config.Services.Spotify.RedirectURL, 146 + Scopes: []string{ 147 + "user-read-recently-played", 148 + }, 149 + Endpoint: spotify.Endpoint, 150 + } 151 + 152 + return func(r chi.Router) { 153 + r.Get("/", func(w http.ResponseWriter, r *http.Request) { 154 + 64 155 render.JSON(w, r, render.M{ 65 156 "message": "Hello World", 66 157 }) 158 + }) 159 + 160 + r.Get("/users/settings/spotify/login", func(w http.ResponseWriter, r *http.Request) { 161 + url := spotifyConfig.AuthCodeURL("super-secret-state") 162 + http.Redirect(w, r, url, http.StatusTemporaryRedirect) 163 + }) 164 + 165 + r.Get("/users/settings/spotify/callback", func(w http.ResponseWriter, r *http.Request) { 166 + state := r.URL.Query().Get("state") 167 + if state != "super-secret-state" { 168 + 169 + http.Error(w, "State mismatch", http.StatusBadRequest) 170 + return 171 + } 172 + 173 + log.Println("state", spotifyConfig) 174 + code := r.URL.Query().Get("code") 175 + log.Println("code", code) 176 + token, err := spotifyConfig.Exchange(r.Context(), code) 177 + if err != nil { 178 + log.Printf("OAuth Exchange Error: %v", err) 179 + http.Error(w, "Couldn't get token", http.StatusInternalServerError) 180 + return 181 + } 182 + 183 + log.Println("does get here without error") 184 + if err := s.services.user.CreateUserService(r.Context(), 1, "spotify", token); err != nil { 185 + http.Error(w, "Couldn't create user", http.StatusInternalServerError) 186 + return 187 + } 188 + 189 + log.Println("does get here without error 2") 190 + 191 + http.Redirect(w, r, "http://localhost:5173/app/settings", http.StatusTemporaryRedirect) 67 192 }) 68 193 } 69 194 }
+4
pkg/bridge/routes.go
··· 40 40 return name 41 41 } 42 42 43 + item = strings.ReplaceAll(item, "-", "_") 44 + item = strings.ReplaceAll(item, ":", "_") 45 + item = strings.ReplaceAll(item, ".", "_") 46 + 43 47 return capitalizeFirstLetter(item) 44 48 }) 45 49 items = enum.Filter(items, func(s string) bool {