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.

create a release page

oscar345 d443e4c4 15d6ce5e

+419 -22
+4 -4
client/src/app.css client/src/styles/app.css
··· 5 5 @import "tailwindcss/utilities.css" layer(utilities); 6 6 7 7 @plugin "@tailwindcss/forms"; 8 - @plugin "./lib/plugins/heroicons.plugin.js"; 8 + @plugin "$lib/plugins/heroicons.plugin.js"; 9 9 10 - @import "./styles/colors.css" layer(theme); 11 - @import "./styles/button.css" layer(components); 12 - @import "./styles/typography.css" layer(components); 10 + @import "./colors.css" layer(theme); 11 + @import "./button.css" layer(components); 12 + @import "./typography.css" layer(components); 13 13 14 14 @utility container { 15 15 padding-inline: var(--spacing-4);
+27 -13
client/src/lib/components/catalog/ReleaseListItem.svelte
··· 10 10 }; 11 11 12 12 let { release, bottom = "none", trailing = "none" }: Props = $props(); 13 + 14 + $inspect(release); 13 15 </script> 14 16 15 17 <article> 16 18 <div> 17 - <img 18 - src="http://localhost:3000/public/images/releases/image.jpg" 19 - alt="" 20 - /> 19 + <img src="http://localhost:3000{release.image_url}" alt="" /> 21 20 </div> 22 21 <div class="text"> 23 - <a href={resolve("/app/catalog/releases/[id]", { id: release.mbid })}> 24 - {release.name} 25 - </a> 22 + <div> 23 + <a href={resolve("/app/catalog/releases/[id]", { id: release.mbid })}> 24 + {release.name} 25 + </a> 26 + </div> 27 + 26 28 {#if bottom === "artists"} 27 29 <ArtistCreditNames artists={release.artists} /> 28 30 {/if} ··· 44 46 column-gap: var(--spacing-2); 45 47 } 46 48 49 + img { 50 + aspect-ratio: 1 / 1; 51 + object-fit: cover; 52 + object-position: center; 53 + width: 100%; 54 + } 55 + 47 56 .plays { 48 57 background-color: var(--color-secondary); 49 58 color: var(--color-secondary-contrast); ··· 65 74 gap: var(--spacing-0_5); 66 75 } 67 76 68 - .text > a { 69 - color: var(--color-primary); 70 - font-size: var(--text-sm); 71 - line-height: var(--leading-tighter); 77 + .text > div { 78 + & > a { 79 + color: var(--color-primary); 80 + font-size: var(--text-sm); 72 81 73 - &:hover { 74 - text-decoration: underline; 82 + &:hover { 83 + text-decoration: underline; 84 + } 75 85 } 86 + 87 + line-height: var(--leading-tighter); 88 + width: max-content; 89 + max-width: 100%; 76 90 } 77 91 78 92 .text > :global(*) {
+1 -1
client/src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 - import "../app.css"; 2 + import "../styles/app.css"; 3 3 import favicon from "$lib/assets/favicon.svg"; 4 4 5 5 let { children } = $props();
+10 -4
client/src/routes/app/catalog/artists/[id]/+page.server.ts
··· 13 13 type Recording, 14 14 } from "$lib/.gen/schemas/responses"; 15 15 import type { 16 - Count as CountRequest, 16 + ArtistCount as CountRequest, 17 17 RecordingCount, 18 18 ReleaseCount, 19 19 } from "$lib/.gen/schemas/requests"; ··· 33 33 const totalCount = call(api, GET_ArtistsByIDCount(params.id), {}).json<Count>(); 34 34 35 35 const lastSevenDays = getCount({ 36 - from: new Date(Date.now() - (7 + 700) * 24 * 60 * 60 * 1000).toISOString(), 36 + date: { 37 + from: new Date(Date.now() - (7 + 700) * 24 * 60 * 60 * 1000).toISOString(), 38 + }, 37 39 } as CountRequest); 38 40 39 41 const lastThirtyDays = getCount({ 40 - from: new Date(Date.now() - (30 + 700) * 24 * 60 * 60 * 1000).toISOString(), 42 + date: { 43 + from: new Date(Date.now() - (30 + 700) * 24 * 60 * 60 * 1000).toISOString(), 44 + }, 41 45 } as CountRequest); 42 46 43 47 const lastYear = getCount({ 44 - from: new Date(Date.now() - (365 + 700) * 24 * 60 * 60 * 1000).toISOString(), 48 + date: { 49 + from: new Date(Date.now() - (365 + 700) * 24 * 60 * 60 * 1000).toISOString(), 50 + }, 45 51 } as CountRequest); 46 52 47 53 const recordings = call(api, GET_Recordings(), {
+68
client/src/routes/app/catalog/releases/[id]/+page.server.ts
··· 1 + import { api, call } from "$lib"; 2 + import { 3 + GET_ArtistsByID, 4 + GET_ArtistsByIDCount, 5 + GET_Recordings, 6 + GET_Releases, 7 + GET_ReleasesByID, 8 + GET_ReleasesByIDCount, 9 + } from "$lib/.gen/routes"; 10 + import { 11 + type Release, 12 + type Artist, 13 + type Count, 14 + type Page, 15 + type Recording, 16 + } from "$lib/.gen/schemas/responses"; 17 + import type { RecordingCount, ReleaseCount } from "$lib/.gen/schemas/requests"; 18 + import type { PageServerLoad } from "./$types"; 19 + 20 + export const load: PageServerLoad = async ({ params }) => { 21 + const release = await call(api, GET_ReleasesByID(params.id), {}).json<Release>(); 22 + 23 + const getCount = (filter: ReleaseCount) => { 24 + return call(api, GET_ReleasesByIDCount(params.id), { 25 + searchParams: { 26 + query: JSON.stringify(filter), 27 + }, 28 + }).json<Count>(); 29 + }; 30 + 31 + const totalCount = call(api, GET_ReleasesByIDCount(params.id), {}).json<Count>(); 32 + 33 + const lastSevenDays = getCount({ 34 + date: { 35 + from: new Date(Date.now() - (7 + 700) * 24 * 60 * 60 * 1000).toISOString(), 36 + }, 37 + } as ReleaseCount); 38 + 39 + const lastThirtyDays = getCount({ 40 + date: { 41 + from: new Date(Date.now() - (30 + 700) * 24 * 60 * 60 * 1000).toISOString(), 42 + }, 43 + } as ReleaseCount); 44 + 45 + const lastYear = getCount({ 46 + date: { 47 + from: new Date(Date.now() - (365 + 700) * 24 * 60 * 60 * 1000).toISOString(), 48 + }, 49 + } as ReleaseCount); 50 + 51 + const recordings = call(api, GET_Recordings(), { 52 + searchParams: { 53 + query: JSON.stringify({ 54 + release_mbid: params.id, 55 + pagination: { size: 10 }, 56 + } as RecordingCount), 57 + }, 58 + }).json<Page<Recording>>(); 59 + 60 + return { 61 + release, 62 + totalCount, 63 + lastSevenDays, 64 + lastThirtyDays, 65 + lastYear, 66 + recordings, 67 + }; 68 + };
+147
client/src/routes/app/catalog/releases/[id]/+page.svelte
··· 1 + <script lang="ts"> 2 + import ArtistCreditNames from "$lib/components/catalog/ArtistCreditNames.svelte"; 3 + import RecordingListItem from "$lib/components/catalog/RecordingListItem.svelte"; 4 + import ReleaseListItem from "$lib/components/catalog/ReleaseListItem.svelte"; 5 + import type { PageProps } from "./$types"; 6 + 7 + let { data, form }: PageProps = $props(); 8 + </script> 9 + 10 + <main class="@container"> 11 + <header class="header"> 12 + <hgroup> 13 + <h1 class="h1">{data.release.name}</h1> 14 + <p class="subtitle"> 15 + <ArtistCreditNames artists={data.release.artists} /> 16 + </p> 17 + </hgroup> 18 + </header> 19 + <div class="image-statistics"> 20 + <figure> 21 + <img src="http://localhost:3000{data.release.image_url}" alt="" /> 22 + </figure> 23 + <div class="@container"> 24 + <section class="statistics"> 25 + <article> 26 + {#await data.totalCount} 27 + Loading... 28 + {:then count} 29 + <p class="h1">{count.value}</p> 30 + {/await} 31 + 32 + <p class="label">Total plays of this artist by all users</p> 33 + </article> 34 + <article> 35 + {#await data.lastSevenDays} 36 + Loading... 37 + {:then count} 38 + <p class="h1">{count.value}</p> 39 + {/await} 40 + 41 + <p class="label">Plays of all users in the last 7 days</p> 42 + </article> 43 + <article> 44 + {#await data.lastThirtyDays} 45 + Loading... 46 + {:then count} 47 + <p class="h1">{count.value}</p> 48 + {/await} 49 + 50 + <p class="label">Plays of all users in the last 30 days</p> 51 + </article> 52 + <article> 53 + {#await data.lastYear} 54 + Loading... 55 + {:then count} 56 + <p class="h1">{count.value}</p> 57 + {/await} 58 + 59 + <p class="label">Plays of all users in the last year</p> 60 + </article> 61 + </section> 62 + </div> 63 + </div> 64 + 65 + <div class="catalog"> 66 + <section> 67 + <header class="header"> 68 + <hgroup> 69 + <h2 class="h2">Recordings</h2> 70 + </hgroup> 71 + </header> 72 + {#await data.recordings then recordings} 73 + {#each recordings.items as recording} 74 + <RecordingListItem 75 + {recording} 76 + bottom="artists" 77 + trailing="plays" 78 + /> 79 + {/each} 80 + {/await} 81 + </section> 82 + </div> 83 + </main> 84 + 85 + <style> 86 + .image-statistics { 87 + display: grid; 88 + grid-template-columns: 1fr; 89 + gap: var(--spacing-section-gap-y); 90 + 91 + @container (width >= 48rem) { 92 + grid-template-columns: repeat(2, 1fr); 93 + } 94 + } 95 + 96 + .statistics { 97 + display: grid; 98 + grid-template-columns: repeat(2, 1fr); 99 + gap: var(--spacing-section-gap-y); 100 + height: 100%; 101 + 102 + @container (width >= 48rem) { 103 + grid-template-columns: repeat(4, 1fr); 104 + } 105 + } 106 + 107 + .catalog { 108 + display: grid; 109 + grid-template-columns: 1fr; 110 + gap: var(--spacing-section-gap-y); 111 + 112 + @container (width >= 48rem) { 113 + grid-template-columns: repeat(2, 1fr); 114 + } 115 + } 116 + 117 + .catalog > section { 118 + display: flex; 119 + flex-direction: column; 120 + gap: var(--spacing-section-gap-y); 121 + border: var(--theme-default-border); 122 + padding: var(--spacing-article-padding); 123 + } 124 + 125 + article { 126 + border: var(--theme-default-border); 127 + padding: var(--spacing-article-padding); 128 + display: flex; 129 + flex-direction: column; 130 + justify-content: space-between; 131 + } 132 + 133 + main { 134 + padding: var(--spacing-main-padding); 135 + display: flex; 136 + flex-direction: column; 137 + gap: var(--spacing-main-gap-y); 138 + } 139 + 140 + figure > img { 141 + width: 100%; 142 + aspect-ratio: 1 / 1; 143 + border: var(--theme-default-border); 144 + object-fit: cover; 145 + object-position: center; 146 + } 147 + </style>
+3
client/src/routes/app/dashboard/+page.svelte
··· 33 33 <Link href="/app/catalog/artists/20244d07-534f-4eff-b4d4-930878889970"> 34 34 taylor swift 35 35 </Link> 36 + <Link href="/app/catalog/artists/68c261d5-48c8-4ea9-9466-9fc908dc79bb"> 37 + magdalena bay 38 + </Link> 36 39 </section> 37 40 </main> 38 41
+159
internal/services/image.go
··· 1 + package services 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "log" 8 + "path" 9 + "sync" 10 + 11 + "github.com/oscar345/keeptrack/internal/image" 12 + storagesvc "github.com/oscar345/keeptrack/pkg/storage" 13 + "golang.org/x/sync/errgroup" 14 + ) 15 + 16 + type imageFetchers struct { 17 + artist image.ArtistImageFetcher 18 + release image.ReleaseImageFetcher 19 + } 20 + 21 + type ImageService struct { 22 + storage storagesvc.Storage 23 + fetchers imageFetchers 24 + } 25 + 26 + func NewImageService( 27 + storage storagesvc.Storage, 28 + artistImageFetcher image.ArtistImageFetcher, 29 + releaseImageFetcher image.ReleaseImageFetcher, 30 + ) *ImageService { 31 + return &ImageService{ 32 + storage: storage, 33 + fetchers: imageFetchers{ 34 + artist: artistImageFetcher, 35 + release: releaseImageFetcher, 36 + }, 37 + } 38 + } 39 + 40 + func (mp *ImageService) GetArtistImage(ctx context.Context, mbid string) (string, error) { 41 + filename := createArtistImageFilename(mbid) 42 + 43 + if mp.storage.Exists(filename) { 44 + return mp.storage.GetURL(filename), nil 45 + } 46 + 47 + images, err := mp.fetchers.artist.ListImages(ctx, mbid) 48 + if err != nil { 49 + return "", err 50 + } 51 + 52 + if len(images) == 0 { 53 + return "", errors.New("no images found") 54 + } 55 + image := images[0] 56 + 57 + content, err := mp.fetchers.artist.FetchImage(ctx, image) 58 + if err != nil { 59 + return "", err 60 + } 61 + 62 + if err := mp.storage.Save(content, filename); err != nil { 63 + return "", err 64 + } 65 + 66 + return mp.storage.GetURL(filename), nil 67 + } 68 + 69 + func (mp *ImageService) ListArtistsImages(ctx context.Context, mbids []string) (map[string]string, error) { 70 + g, ctx := errgroup.WithContext(ctx) 71 + 72 + var mu sync.Mutex 73 + result := make(map[string]string, len(mbids)) 74 + 75 + for _, mbid := range mbids { 76 + g.Go(func() error { 77 + if url, err := mp.GetArtistImage(ctx, mbid); err == nil { 78 + mu.Lock() 79 + result[mbid] = url 80 + mu.Unlock() 81 + } else { 82 + log.Printf("failed to get artist image for %s: %v", mbid, err) 83 + } 84 + return nil 85 + }) 86 + } 87 + 88 + if err := g.Wait(); err != nil { 89 + return nil, err 90 + } 91 + 92 + return result, nil 93 + } 94 + 95 + func createArtistImageFilename(mbid string) string { 96 + return path.Join("images", "artists", mbid) 97 + } 98 + 99 + func createReleaseImageFilename(mbid string) string { 100 + return path.Join("images", "releases", mbid) 101 + } 102 + 103 + func (mp *ImageService) GetReleaseImage(ctx context.Context, mbid string) (string, error) { 104 + filename := createReleaseImageFilename(mbid) 105 + if mp.storage.Exists(filename) { 106 + return mp.storage.GetURL(filename), nil 107 + } 108 + 109 + images, err := mp.fetchers.release.ListImages(ctx, mbid) 110 + if err != nil { 111 + return "", err 112 + } 113 + 114 + if len(images) == 0 { 115 + return "", errors.New("no images found") 116 + } 117 + 118 + image := images[0] 119 + 120 + content, err := mp.fetchers.release.FetchImage(ctx, image) 121 + if err != nil { 122 + return "", err 123 + } 124 + 125 + if err := mp.storage.Save(content, filename); err != nil { 126 + return "", err 127 + } 128 + 129 + return mp.storage.GetURL(filename), nil 130 + } 131 + 132 + func (mp *ImageService) ListReleasesImages(ctx context.Context, mbids []string) (map[string]string, error) { 133 + g, ctx := errgroup.WithContext(ctx) 134 + 135 + var mu sync.Mutex 136 + result := make(map[string]string, len(mbids)) 137 + 138 + fmt.Println("mbids from 2", mbids) 139 + 140 + for _, mbid := range mbids { 141 + g.Go(func() error { 142 + if image, err := mp.GetReleaseImage(ctx, mbid); err == nil { 143 + fmt.Println("result if on error from 4", image) 144 + mu.Lock() 145 + result[mbid] = image 146 + mu.Unlock() 147 + } 148 + 149 + return nil 150 + }) 151 + } 152 + 153 + if err := g.Wait(); err != nil { 154 + fmt.Println("error from 3", err) 155 + return nil, err 156 + } 157 + 158 + return result, nil 159 + }