Add Last.fm music stats to about page
This commit is contained in:
1 parent
3122bc4bf9
commit
2d84d68c46
27 files changed
+1618
-4
No files matched your search
@@ -18,5 +18,9 @@ export const variables = defineEnvVars({
|
||||
OMDB_API_KEY: {
|
||||
schema: optional,
|
||||
description: 'OMDb API key used to load posters and details for shows and movies'
|
||||
},
|
||||
LASTFM_API_KEY: {
|
||||
schema: optional,
|
||||
description: 'Last.fm API key used to collect scrobbles for the listening stats'
|
||||
}
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { ServerInit } from '@sveltejs/kit';
|
||||
import { getDiscordClient } from '#lib/server/discord';
|
||||
import { refreshMusicCache } from '#lib/server/musicCache';
|
||||
import { startLastfmSync } from '#lib/server/lastfm/sync';
|
||||
import { about } from '#lib/content';
|
||||
|
||||
export const init: ServerInit = () => {
|
||||
getDiscordClient();
|
||||
refreshMusicCache();
|
||||
startLastfmSync(about.lastfmUsername);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { ListeningProfile } from '#lib/lastfm/types';
|
||||
import Panel from '../../ui/Panel.svelte';
|
||||
import BarChart from './BarChart.svelte';
|
||||
import TabRow from './TabRow.svelte';
|
||||
|
||||
let { listening }: { listening: ListeningProfile } = $props();
|
||||
|
||||
const views = [
|
||||
{ id: 'daily', label: '30 days', color: 'accent' },
|
||||
{ id: 'monthly', label: '12 months', color: 'blue' },
|
||||
{ id: 'hourly', label: 'By hour', color: 'magenta' }
|
||||
] as const;
|
||||
|
||||
let selectedView = $state<(typeof views)[number]['id']>('daily');
|
||||
|
||||
const selected = $derived(views.find((view) => view.id === selectedView)!);
|
||||
const buckets = $derived(listening[selectedView]);
|
||||
</script>
|
||||
|
||||
<Panel title="Listening activity" icon="mdi:chart-bar">
|
||||
<TabRow tabs={views} bind:selected={selectedView} label="Activity range" />
|
||||
<BarChart {buckets} color={selected.color} />
|
||||
</Panel>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import type { CountBucket } from '#lib/lastfm/types';
|
||||
|
||||
let { buckets, color = 'accent', unit = 'plays' }: { buckets: CountBucket[]; color?: string; unit?: string } = $props();
|
||||
|
||||
const highest = $derived(Math.max(1, ...buckets.map((bucket) => bucket.count)));
|
||||
const peak = $derived(buckets.reduce((best, bucket) => (bucket.count > best.count ? bucket : best), buckets[0]));
|
||||
</script>
|
||||
|
||||
<div class="chart" role="img" aria-label="Bar chart, peak {peak?.title} with {peak?.count} {unit}">
|
||||
<div class="bars">
|
||||
{#each buckets as bucket, index (index)}
|
||||
<div class="column" title="{bucket.title}: {bucket.count} {unit}">
|
||||
<div class="bar" style:height="{(bucket.count / highest) * 100}%" style:background="var(--{color})"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="labels">
|
||||
{#each buckets as bucket, index (index)}
|
||||
<span>{bucket.label}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{#if peak?.count}
|
||||
<p class="muted peak">peak: {peak.title} ({peak.count} {unit})</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bars,
|
||||
.labels {
|
||||
display: grid;
|
||||
grid-auto-columns: minmax(0, 1fr);
|
||||
grid-auto-flow: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bars {
|
||||
height: 110px;
|
||||
border-bottom: 1px dashed color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.column:hover .bar {
|
||||
filter: brightness(1.3);
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 100%;
|
||||
min-height: 1px;
|
||||
opacity: 0.85;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.labels {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.65rem;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.peak {
|
||||
margin: 0.4rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import type { ListeningProfile } from '#lib/lastfm/types';
|
||||
|
||||
let { listening }: { listening: ListeningProfile } = $props();
|
||||
|
||||
const hostName = 'last.fm';
|
||||
const numberFormat = new Intl.NumberFormat('en-US');
|
||||
|
||||
const memberSince = $derived(
|
||||
listening.registeredAt
|
||||
? new Date(listening.registeredAt * 1000).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })
|
||||
: null
|
||||
);
|
||||
|
||||
const rows = $derived(
|
||||
[
|
||||
['Scrobbles', numberFormat.format(listening.totals.scrobbles)],
|
||||
['Artists', listening.totals.artists ? numberFormat.format(listening.totals.artists) : null],
|
||||
['Albums', listening.totals.albums ? numberFormat.format(listening.totals.albums) : null],
|
||||
['Tracks', listening.totals.tracks ? numberFormat.format(listening.totals.tracks) : null],
|
||||
['Daily avg', `${listening.averagePerDay} plays (30 days)`],
|
||||
['Streak', `${listening.currentStreak} days (best ${listening.longestStreak})`],
|
||||
['Since', memberSince]
|
||||
].filter((row): row is [string, string] => row[1] !== null)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="listening-fetch">
|
||||
<a class="heading" href={listening.profileUrl} target="_blank" rel="noopener noreferrer">
|
||||
<span class="user">{listening.userName.toLowerCase()}</span><span class="at">@</span><span class="host">{hostName}</span>
|
||||
</a>
|
||||
<div class="separator">{'-'.repeat(listening.userName.length + hostName.length + 1)}</div>
|
||||
|
||||
{#each rows as [label, value] (label)}
|
||||
<div class="row"><span class="label">{label}</span>: {value}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.listening-fetch {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.user,
|
||||
.host {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.heading:hover .user,
|
||||
.heading:hover .host {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.at,
|
||||
.separator {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--blue);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import type { ListeningProfile } from '#lib/lastfm/types';
|
||||
import LinkButton from '../../ui/LinkButton.svelte';
|
||||
import Panel from '../../ui/Panel.svelte';
|
||||
import ActivityPanel from './ActivityPanel.svelte';
|
||||
import ListeningFetch from './ListeningFetch.svelte';
|
||||
import RecentTracks from './RecentTracks.svelte';
|
||||
import TopCharts from './TopCharts.svelte';
|
||||
import TrendingArtists from './TrendingArtists.svelte';
|
||||
|
||||
let { listening }: { listening: ListeningProfile } = $props();
|
||||
</script>
|
||||
|
||||
<div class="overview">
|
||||
<Panel title="last.fm" icon="simple-icons:lastdotfm">
|
||||
<ListeningFetch {listening} />
|
||||
</Panel>
|
||||
<Panel title={listening.nowPlaying ? 'Now playing' : 'Recently played'} icon="mdi:headphones">
|
||||
<RecentTracks nowPlaying={listening.nowPlaying} recentTracks={listening.recentTracks} />
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div class="overview">
|
||||
<ActivityPanel {listening} />
|
||||
<Panel title="Trending this week" icon="mdi:trending-up">
|
||||
<TrendingArtists artists={listening.trending} />
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<h3 class="subheading"><Icon icon="mdi:trophy-outline" width="16" /> Top charts</h3>
|
||||
<TopCharts top={listening.top} />
|
||||
|
||||
<div class="actions">
|
||||
<LinkButton href={listening.profileUrl} icon="simple-icons:lastdotfm">Full history on Last.fm</LinkButton>
|
||||
<span class="muted stored">{listening.storedScrobbles.toLocaleString('en-US')} scrobbles collected</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overview {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.subheading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 1.25rem 0 0.6rem;
|
||||
color: var(--foreground);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.stored {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@container (max-width: 800px) {
|
||||
.overview {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import type { PlayedTrack } from '#lib/lastfm/types';
|
||||
|
||||
let { nowPlaying, recentTracks }: { nowPlaying: PlayedTrack | null; recentTracks: PlayedTrack[] } = $props();
|
||||
|
||||
const shownCount = 5;
|
||||
|
||||
const tracks = $derived(
|
||||
[...(nowPlaying ? [nowPlaying] : []), ...recentTracks].slice(0, shownCount)
|
||||
);
|
||||
|
||||
function timeAgo(playedAt: number | null): string {
|
||||
if (playedAt === null) return 'now';
|
||||
const minutes = Math.floor((Date.now() / 1000 - playedAt) / 60);
|
||||
if (minutes < 60) return `${Math.max(1, minutes)}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if tracks.length}
|
||||
<ul class="tracks">
|
||||
{#each tracks as track, index (`${track.playedAt}-${index}`)}
|
||||
<li>
|
||||
<a class="track" class:playing={track.playedAt === null} href={track.url} target="_blank" rel="noopener noreferrer">
|
||||
{#if track.image}
|
||||
<img src={track.image} alt={track.album} loading="lazy" />
|
||||
{:else}
|
||||
<span class="cover-fallback"><Icon icon="mdi:music-note" width="18" /></span>
|
||||
{/if}
|
||||
<span class="text">
|
||||
<span class="name">{track.track}</span>
|
||||
<span class="muted artist">{track.artist}</span>
|
||||
</span>
|
||||
<span class="when">
|
||||
{#if track.playedAt === null}
|
||||
<span class="equalizer" aria-label="Now playing"><span></span><span></span><span></span></span>
|
||||
{:else}
|
||||
<span class="muted">{timeAgo(track.playedAt)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="muted">No scrobbles yet.</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.tracks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.3rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.track:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.track.playing {
|
||||
border-color: color-mix(in srgb, var(--green) 50%, transparent);
|
||||
}
|
||||
|
||||
img,
|
||||
.cover-fallback {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover-fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name,
|
||||
.artist {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.artist,
|
||||
.when {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.when {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.equalizer {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.equalizer span {
|
||||
width: 3px;
|
||||
background: var(--green);
|
||||
animation: equalize 0.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.equalizer span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.equalizer span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes equalize {
|
||||
0%,
|
||||
100% {
|
||||
height: 30%;
|
||||
}
|
||||
50% {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.equalizer span {
|
||||
animation: none;
|
||||
height: 70%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts" generics="TabId extends string">
|
||||
let {
|
||||
tabs,
|
||||
selected = $bindable(),
|
||||
label
|
||||
}: { tabs: readonly { id: TabId; label: string }[]; selected: TabId; label: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="tabs" role="tablist" aria-label={label}>
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button role="tab" aria-selected={selected === tab.id} class:active={selected === tab.id} onclick={() => (selected = tab.id)}>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.15rem 0.6rem;
|
||||
background: none;
|
||||
border: 1px dashed color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
button.active {
|
||||
border-style: solid;
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,236 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import type { ChartItem, ListeningPeriod, TopCharts } from '#lib/lastfm/types';
|
||||
import Panel from '../../ui/Panel.svelte';
|
||||
import TabRow from './TabRow.svelte';
|
||||
|
||||
let { top }: { top: Record<ListeningPeriod, TopCharts> } = $props();
|
||||
|
||||
const periods: { id: ListeningPeriod; label: string }[] = [
|
||||
{ id: 'week', label: '7 days' },
|
||||
{ id: 'month', label: '30 days' },
|
||||
{ id: 'year', label: '12 months' },
|
||||
{ id: 'all', label: 'All time' }
|
||||
];
|
||||
|
||||
let selectedPeriod = $state<ListeningPeriod>('month');
|
||||
|
||||
const charts = $derived(top[selectedPeriod]);
|
||||
const lists = $derived([
|
||||
{ title: 'Top artists', icon: 'mdi:account-music', items: charts.artists, showCovers: false },
|
||||
{ title: 'Top tracks', icon: 'mdi:music-note', items: charts.tracks, showCovers: true }
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="top-charts">
|
||||
<TabRow tabs={periods} bind:selected={selectedPeriod} label="Chart period" />
|
||||
|
||||
<div class="album-grid">
|
||||
{#each charts.albums as album (album.url)}
|
||||
<a class="album" href={album.url} target="_blank" rel="noopener noreferrer" title="{album.name} by {album.artist}">
|
||||
<div class="cover">
|
||||
{#if album.image}
|
||||
<img src={album.image} alt={album.name} loading="lazy" />
|
||||
{:else}
|
||||
<span class="cover-fallback"><Icon icon="mdi:album" width="32" /></span>
|
||||
{/if}
|
||||
<span class="plays">{album.count}</span>
|
||||
</div>
|
||||
<span class="album-name">{album.name}</span>
|
||||
<span class="muted album-artist">{album.artist}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<p class="muted">No albums in this period.</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="lists">
|
||||
{#each lists as list (list.title)}
|
||||
<Panel title={list.title} icon={list.icon}>
|
||||
{@render chartList(list.items, list.showCovers)}
|
||||
</Panel>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet chartList(items: ChartItem[], showCovers: boolean)}
|
||||
{#if items.length}
|
||||
<ol class="chart-list">
|
||||
{#each items as item, index (item.url)}
|
||||
<li>
|
||||
<span class="rank">{index + 1}</span>
|
||||
{#if showCovers}
|
||||
{#if item.image}
|
||||
<img class="thumb" src={item.image} alt="" loading="lazy" />
|
||||
{:else}
|
||||
<span class="thumb thumb-fallback"><Icon icon="mdi:music-note" width="16" /></span>
|
||||
{/if}
|
||||
{/if}
|
||||
<a class="item" href={item.url} target="_blank" rel="noopener noreferrer">
|
||||
<span class="item-name">{item.name}</span>
|
||||
{#if item.artist}
|
||||
<span class="muted item-artist">{item.artist}</span>
|
||||
{/if}
|
||||
</a>
|
||||
<span class="muted">{item.count}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else}
|
||||
<p class="muted">Nothing here yet.</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.top-charts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.album-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: 0.9rem 0.75rem;
|
||||
}
|
||||
|
||||
.album {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cover {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.album:hover .cover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.album:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.cover-fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.plays {
|
||||
position: absolute;
|
||||
top: 0.3rem;
|
||||
right: 0.3rem;
|
||||
padding: 0 0.35rem;
|
||||
background: var(--background);
|
||||
color: var(--accent);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.album-name,
|
||||
.album-artist,
|
||||
.item-name,
|
||||
.item-artist {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.album-name {
|
||||
margin-top: 0.15rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.album:hover .album-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.album-artist {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.lists {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.chart-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.rank {
|
||||
flex-shrink: 0;
|
||||
width: 1.4rem;
|
||||
color: var(--muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumb-fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item:hover .item-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.item-artist {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
@container (max-width: 800px) {
|
||||
.lists {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 520px) {
|
||||
.album-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import type { TrendingArtist } from '#lib/lastfm/types';
|
||||
|
||||
let { artists }: { artists: TrendingArtist[] } = $props();
|
||||
|
||||
const highest = $derived(Math.max(1, ...artists.map((artist) => artist.count)));
|
||||
|
||||
function changeOf(artist: TrendingArtist) {
|
||||
if (artist.previousCount === 0) return null;
|
||||
const percent = Math.round(((artist.count - artist.previousCount) / artist.previousCount) * 100);
|
||||
if (percent > 0) return { icon: 'mdi:arrow-up', color: 'green', text: `${percent}%` };
|
||||
if (percent < 0) return { icon: 'mdi:arrow-down', color: 'red', text: `${Math.abs(percent)}%` };
|
||||
return { icon: 'mdi:minus', color: 'muted', text: 'same' };
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if artists.length}
|
||||
<ol class="trending">
|
||||
{#each artists as artist, index (artist.name)}
|
||||
{@const change = changeOf(artist)}
|
||||
<li>
|
||||
<span class="rank">{index + 1}</span>
|
||||
<a class="name" href={artist.url} target="_blank" rel="noopener noreferrer">{artist.name}</a>
|
||||
<span class="meter"><span class="fill" style:width="{(artist.count / highest) * 100}%"></span></span>
|
||||
<span class="count">{artist.count}</span>
|
||||
<span class="change" style:color={change ? `var(--${change.color})` : undefined} title="{artist.previousCount} plays the week before">
|
||||
{#if change}
|
||||
<Icon icon={change.icon} width="14" />{change.text}
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else}
|
||||
<p class="muted">Nothing played this week.</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.trending {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4rem minmax(0, 1.2fr) minmax(0, 1fr) 2.5rem 4rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.rank {
|
||||
color: var(--muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.name:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.meter {
|
||||
height: 6px;
|
||||
background: color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||
}
|
||||
|
||||
.fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.count {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.change {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@container (max-width: 520px) {
|
||||
li {
|
||||
grid-template-columns: 1.4rem minmax(0, 1fr) 2.5rem 4rem;
|
||||
}
|
||||
|
||||
.meter {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -139,6 +139,7 @@ export interface RatedTitle {
|
||||
|
||||
export interface AboutContent {
|
||||
anilistUsername?: string;
|
||||
lastfmUsername?: string;
|
||||
hobbies: Hobby[];
|
||||
games: Game[];
|
||||
shows: RatedTitle[];
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
export type ListeningPeriod = 'week' | 'month' | 'year' | 'all';
|
||||
|
||||
export interface PlayedTrack {
|
||||
track: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
url: string;
|
||||
image: string | null;
|
||||
playedAt: number | null;
|
||||
}
|
||||
|
||||
export interface CountBucket {
|
||||
label: string;
|
||||
title: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ChartItem {
|
||||
name: string;
|
||||
artist: string | null;
|
||||
count: number;
|
||||
url: string;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
export interface TopCharts {
|
||||
artists: ChartItem[];
|
||||
albums: ChartItem[];
|
||||
tracks: ChartItem[];
|
||||
}
|
||||
|
||||
export interface TrendingArtist {
|
||||
name: string;
|
||||
url: string;
|
||||
count: number;
|
||||
previousCount: number;
|
||||
}
|
||||
|
||||
export interface ListeningTotals {
|
||||
scrobbles: number;
|
||||
artists: number;
|
||||
albums: number;
|
||||
tracks: number;
|
||||
}
|
||||
|
||||
export interface ListeningProfile {
|
||||
userName: string;
|
||||
profileUrl: string;
|
||||
registeredAt: number | null;
|
||||
totals: ListeningTotals;
|
||||
nowPlaying: PlayedTrack | null;
|
||||
recentTracks: PlayedTrack[];
|
||||
daily: CountBucket[];
|
||||
monthly: CountBucket[];
|
||||
hourly: CountBucket[];
|
||||
averagePerDay: number;
|
||||
currentStreak: number;
|
||||
longestStreak: number;
|
||||
trending: TrendingArtist[];
|
||||
top: Record<ListeningPeriod, TopCharts>;
|
||||
storedScrobbles: number;
|
||||
syncedAt: number | null;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { LASTFM_API_KEY } from '$app/env/private';
|
||||
import type { PlayedTrack } from '#lib/lastfm/types';
|
||||
|
||||
const apiUrl = 'https://ws.audioscrobbler.com/2.0/';
|
||||
const pageSize = 200;
|
||||
const placeholderImageId = '2a96cbd8b46e442fc41c2b86b821562f';
|
||||
|
||||
interface RawImage {
|
||||
size: string;
|
||||
'#text': string;
|
||||
}
|
||||
|
||||
interface RawTrack {
|
||||
name: string;
|
||||
url: string;
|
||||
artist: { '#text': string };
|
||||
album: { '#text': string };
|
||||
image?: RawImage[];
|
||||
date?: { uts: string };
|
||||
'@attr'?: { nowplaying?: string };
|
||||
}
|
||||
|
||||
interface RawRecentTracks {
|
||||
recenttracks: {
|
||||
track: RawTrack[] | RawTrack;
|
||||
'@attr': { totalPages: string };
|
||||
};
|
||||
}
|
||||
|
||||
interface RawUserInfo {
|
||||
user: {
|
||||
name: string;
|
||||
url: string;
|
||||
playcount: string;
|
||||
artist_count: string;
|
||||
album_count: string;
|
||||
track_count: string;
|
||||
registered?: { unixtime: string };
|
||||
};
|
||||
}
|
||||
|
||||
interface RawTrackInfo {
|
||||
track?: { album?: { image?: RawImage[] } };
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
name: string;
|
||||
url: string;
|
||||
scrobbles: number;
|
||||
artists: number;
|
||||
albums: number;
|
||||
tracks: number;
|
||||
registeredAt: number | null;
|
||||
}
|
||||
|
||||
export interface RecentTracksPage {
|
||||
tracks: PlayedTrack[];
|
||||
nowPlaying: PlayedTrack | null;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export function hasLastfmKey(): boolean {
|
||||
return Boolean(LASTFM_API_KEY);
|
||||
}
|
||||
|
||||
async function callLastfm<T>(method: string, params: Record<string, string>): Promise<T> {
|
||||
const query = new URLSearchParams({ method, api_key: LASTFM_API_KEY ?? '', format: 'json', ...params });
|
||||
const response = await fetch(`${apiUrl}?${query}`, { headers: { 'User-Agent': 'sirblob-website' } });
|
||||
if (!response.ok) throw new Error(`Last.fm ${method} returned ${response.status}`);
|
||||
|
||||
const body = await response.json();
|
||||
if (body.error) throw new Error(`Last.fm ${method} failed: ${body.message}`);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
function largestImage(images: RawImage[] | undefined): string | null {
|
||||
const url = images?.at(-1)?.['#text'];
|
||||
return url && !url.includes(placeholderImageId) ? url : null;
|
||||
}
|
||||
|
||||
function toPlayedTrack(raw: RawTrack): PlayedTrack {
|
||||
return {
|
||||
track: raw.name,
|
||||
artist: raw.artist['#text'],
|
||||
album: raw.album['#text'],
|
||||
url: raw.url,
|
||||
image: largestImage(raw.image),
|
||||
playedAt: raw.date ? Number(raw.date.uts) : null
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchUserInfo(userName: string): Promise<UserInfo> {
|
||||
const { user } = await callLastfm<RawUserInfo>('user.getinfo', { user: userName });
|
||||
return {
|
||||
name: user.name,
|
||||
url: user.url,
|
||||
scrobbles: Number(user.playcount),
|
||||
artists: Number(user.artist_count),
|
||||
albums: Number(user.album_count),
|
||||
tracks: Number(user.track_count),
|
||||
registeredAt: user.registered ? Number(user.registered.unixtime) : null
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchRecentTracksPage(
|
||||
userName: string,
|
||||
page: number,
|
||||
range: { from?: number; to: number }
|
||||
): Promise<RecentTracksPage> {
|
||||
const params: Record<string, string> = { user: userName, page: `${page}`, limit: `${pageSize}`, to: `${range.to}` };
|
||||
if (range.from) params.from = `${range.from}`;
|
||||
|
||||
const { recenttracks } = await callLastfm<RawRecentTracks>('user.getrecenttracks', params);
|
||||
const rawTracks = Array.isArray(recenttracks.track) ? recenttracks.track : [recenttracks.track];
|
||||
const nowPlayingRaw = rawTracks.find((raw) => raw['@attr']?.nowplaying === 'true');
|
||||
|
||||
return {
|
||||
tracks: rawTracks.filter((raw) => raw.date).map(toPlayedTrack),
|
||||
nowPlaying: nowPlayingRaw ? toPlayedTrack(nowPlayingRaw) : null,
|
||||
totalPages: Number(recenttracks['@attr'].totalPages)
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchNowPlaying(userName: string): Promise<PlayedTrack | null> {
|
||||
const { recenttracks } = await callLastfm<RawRecentTracks>('user.getrecenttracks', { user: userName, limit: '1' });
|
||||
const rawTracks = Array.isArray(recenttracks.track) ? recenttracks.track : [recenttracks.track];
|
||||
const nowPlayingRaw = rawTracks.find((raw) => raw['@attr']?.nowplaying === 'true');
|
||||
return nowPlayingRaw ? toPlayedTrack(nowPlayingRaw) : null;
|
||||
}
|
||||
|
||||
export async function fetchTrackImage(artist: string, track: string): Promise<string | null> {
|
||||
const { track: info } = await callLastfm<RawTrackInfo>('track.getinfo', { artist, track, autocorrect: '1' });
|
||||
return largestImage(info?.album?.image);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
const timeZone = 'America/New_York';
|
||||
const secondsPerHour = 3600;
|
||||
const millisecondsPerDay = 24 * 60 * 60 * 1000;
|
||||
|
||||
const partsFormatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hourCycle: 'h23'
|
||||
});
|
||||
|
||||
const hourCache = new Map<number, { dayKey: string; hour: number }>();
|
||||
|
||||
export function localTime(unixSeconds: number): { dayKey: string; hour: number } {
|
||||
const hourIndex = Math.floor(unixSeconds / secondsPerHour);
|
||||
const cached = hourCache.get(hourIndex);
|
||||
if (cached) return cached;
|
||||
|
||||
const parts = Object.fromEntries(
|
||||
partsFormatter.formatToParts(new Date(hourIndex * secondsPerHour * 1000)).map((part) => [part.type, part.value])
|
||||
);
|
||||
const result = { dayKey: `${parts.year}-${parts.month}-${parts.day}`, hour: Number(parts.hour) };
|
||||
hourCache.set(hourIndex, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function todayKey(): string {
|
||||
return localTime(Math.floor(Date.now() / 1000)).dayKey;
|
||||
}
|
||||
|
||||
export function shiftDayKey(dayKey: string, days: number): string {
|
||||
const date = new Date(`${dayKey}T00:00:00Z`);
|
||||
return new Date(date.getTime() + days * millisecondsPerDay).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function shiftMonthKey(monthKey: string, months: number): string {
|
||||
const [year, month] = monthKey.split('-').map(Number);
|
||||
const date = new Date(Date.UTC(year, month - 1 + months, 1));
|
||||
return date.toISOString().slice(0, 7);
|
||||
}
|
||||
|
||||
export function daysBetween(firstKey: string, secondKey: string): number {
|
||||
return Math.round((Date.parse(`${secondKey}T00:00:00Z`) - Date.parse(`${firstKey}T00:00:00Z`)) / millisecondsPerDay);
|
||||
}
|
||||
|
||||
export function formatDayLabel(dayKey: string, options: Intl.DateTimeFormatOptions): string {
|
||||
return new Date(`${dayKey}T12:00:00Z`).toLocaleDateString('en-US', { ...options, timeZone: 'UTC' });
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fetchTrackImage } from './api';
|
||||
|
||||
const dataDirectory = path.resolve(process.env.LASTFM_DATA_DIR ?? 'data/lastfm');
|
||||
const albumCoversPath = path.join(dataDirectory, 'covers.json');
|
||||
const trackCoversPath = path.join(dataDirectory, 'track-covers.json');
|
||||
const imageDirectory = path.join(dataDirectory, 'images');
|
||||
const imageIdPattern = /^[0-9a-f]{16}$/;
|
||||
const lookupDelay = 300;
|
||||
const notFound = '';
|
||||
|
||||
let albumCovers: Record<string, string> = {};
|
||||
let trackCovers: Record<string, string> = {};
|
||||
const savedImages = new Map<string, string>();
|
||||
const queuedDownloads = new Set<string>();
|
||||
const queuedLookups = new Map<string, { artist: string; track: string }>();
|
||||
let loading: Promise<void> | null = null;
|
||||
let workerRunning = false;
|
||||
let version = 0;
|
||||
|
||||
const wait = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
const pairKey = (first: string, second: string) => `${first}\n${second}`;
|
||||
const imageId = (url: string) => createHash('sha1').update(url).digest('hex').slice(0, 16);
|
||||
|
||||
async function readJson(filePath: string): Promise<Record<string, string>> {
|
||||
try {
|
||||
return JSON.parse(await readFile(filePath, 'utf8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFromDisk() {
|
||||
await mkdir(imageDirectory, { recursive: true });
|
||||
albumCovers = await readJson(albumCoversPath);
|
||||
trackCovers = await readJson(trackCoversPath);
|
||||
for (const fileName of await readdir(imageDirectory)) {
|
||||
savedImages.set(path.parse(fileName).name, fileName);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadCovers(): Promise<void> {
|
||||
loading ??= loadFromDisk();
|
||||
return loading;
|
||||
}
|
||||
|
||||
export function coversVersion(): number {
|
||||
return version;
|
||||
}
|
||||
|
||||
function servedImage(url: string): string {
|
||||
const id = imageId(url);
|
||||
if (savedImages.has(id)) return `/api/lastfm/covers/${id}`;
|
||||
queuedDownloads.add(url);
|
||||
runWorker();
|
||||
return url;
|
||||
}
|
||||
|
||||
export function coverFor(artist: string, album: string, track?: string): string | null {
|
||||
const url = (album && albumCovers[pairKey(artist, album)]) || (track && trackCovers[pairKey(artist, track)]) || null;
|
||||
return url ? servedImage(url) : null;
|
||||
}
|
||||
|
||||
export async function rememberAlbumCovers(entries: { artist: string; album: string; image: string | null }[]) {
|
||||
let changed = false;
|
||||
for (const { artist, album, image } of entries) {
|
||||
const key = pairKey(artist, album);
|
||||
if (!album || !image || albumCovers[key] === image) continue;
|
||||
albumCovers[key] = image;
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return;
|
||||
version++;
|
||||
await writeFile(albumCoversPath, JSON.stringify(albumCovers));
|
||||
}
|
||||
|
||||
export function requestTrackCovers(tracks: { artist: string; track: string }[]) {
|
||||
for (const { artist, track } of tracks) {
|
||||
const key = pairKey(artist, track);
|
||||
if (key in trackCovers || queuedLookups.has(key)) continue;
|
||||
queuedLookups.set(key, { artist, track });
|
||||
}
|
||||
runWorker();
|
||||
}
|
||||
|
||||
async function downloadImage(url: string) {
|
||||
const id = imageId(url);
|
||||
if (savedImages.has(id)) return;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`cover download returned ${response.status}`);
|
||||
|
||||
const fileName = `${id}${path.extname(new URL(url).pathname) || '.jpg'}`;
|
||||
await writeFile(path.join(imageDirectory, fileName), Buffer.from(await response.arrayBuffer()));
|
||||
savedImages.set(id, fileName);
|
||||
version++;
|
||||
}
|
||||
|
||||
async function lookUpTrackCover(key: string, artist: string, track: string) {
|
||||
const image = await fetchTrackImage(artist, track);
|
||||
trackCovers[key] = image ?? notFound;
|
||||
await writeFile(trackCoversPath, JSON.stringify(trackCovers));
|
||||
if (image) version++;
|
||||
}
|
||||
|
||||
async function processQueues() {
|
||||
while (queuedLookups.size || queuedDownloads.size) {
|
||||
const [lookupKey, lookup] = queuedLookups.entries().next().value ?? [];
|
||||
if (lookupKey && lookup) {
|
||||
queuedLookups.delete(lookupKey);
|
||||
await lookUpTrackCover(lookupKey, lookup.artist, lookup.track).catch((error) =>
|
||||
console.error(`Last.fm cover lookup failed for ${lookup.track}:`, error instanceof Error ? error.message : error)
|
||||
);
|
||||
await wait(lookupDelay);
|
||||
}
|
||||
|
||||
const url = queuedDownloads.values().next().value;
|
||||
if (url) {
|
||||
queuedDownloads.delete(url);
|
||||
await downloadImage(url).catch((error) =>
|
||||
console.error('Last.fm cover download failed:', error instanceof Error ? error.message : error)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runWorker() {
|
||||
if (workerRunning) return;
|
||||
workerRunning = true;
|
||||
loadCovers()
|
||||
.then(processQueues)
|
||||
.finally(() => (workerRunning = false));
|
||||
}
|
||||
|
||||
export function savedCoverPath(id: string): string | null {
|
||||
const fileName = imageIdPattern.test(id) ? savedImages.get(id) : undefined;
|
||||
return fileName ? path.join(imageDirectory, fileName) : null;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { ListeningProfile, PlayedTrack } from '#lib/lastfm/types';
|
||||
import { fetchNowPlaying, fetchUserInfo, hasLastfmKey, type UserInfo } from './api';
|
||||
import { loadScrobbleStore, storedScrobbles } from './store';
|
||||
import { coverFor, coversVersion, loadCovers, requestTrackCovers } from './covers';
|
||||
import { lastfmSyncedAt } from './sync';
|
||||
import {
|
||||
countsByDay,
|
||||
dailyBuckets,
|
||||
hourlyBuckets,
|
||||
listeningStreaks,
|
||||
monthlyBuckets,
|
||||
topChartsByPeriod,
|
||||
trendingArtists
|
||||
} from './trends';
|
||||
|
||||
const userInfoDuration = 30 * 60 * 1000;
|
||||
const nowPlayingDuration = 60 * 1000;
|
||||
const summaryDuration = 5 * 60 * 1000;
|
||||
const recentCount = 8;
|
||||
|
||||
type Summary = Omit<ListeningProfile, 'userName' | 'profileUrl' | 'registeredAt' | 'totals' | 'nowPlaying' | 'syncedAt'>;
|
||||
|
||||
let userInfoCache: { info: UserInfo; fetchedAt: number } | null = null;
|
||||
let nowPlayingCache: { track: PlayedTrack | null; fetchedAt: number } | null = null;
|
||||
let summaryCache: { summary: Summary; scrobbleCount: number; coversVersion: number; builtAt: number } | null = null;
|
||||
|
||||
async function cachedUserInfo(userName: string): Promise<UserInfo | null> {
|
||||
if (userInfoCache && Date.now() - userInfoCache.fetchedAt < userInfoDuration) return userInfoCache.info;
|
||||
try {
|
||||
userInfoCache = { info: await fetchUserInfo(userName), fetchedAt: Date.now() };
|
||||
} catch (error) {
|
||||
console.error('Last.fm user lookup failed:', error instanceof Error ? error.message : error);
|
||||
}
|
||||
return userInfoCache?.info ?? null;
|
||||
}
|
||||
|
||||
async function cachedNowPlaying(userName: string): Promise<PlayedTrack | null> {
|
||||
if (nowPlayingCache && Date.now() - nowPlayingCache.fetchedAt < nowPlayingDuration) return nowPlayingCache.track;
|
||||
try {
|
||||
nowPlayingCache = { track: await fetchNowPlaying(userName), fetchedAt: Date.now() };
|
||||
} catch (error) {
|
||||
console.error('Last.fm now playing failed:', error instanceof Error ? error.message : error);
|
||||
}
|
||||
return nowPlayingCache?.track ?? null;
|
||||
}
|
||||
|
||||
function recentTracks(): PlayedTrack[] {
|
||||
return storedScrobbles()
|
||||
.slice(-recentCount)
|
||||
.reverse()
|
||||
.map((scrobble) => ({
|
||||
...scrobble,
|
||||
url: `https://www.last.fm/music/${encodeURIComponent(scrobble.artist)}/_/${encodeURIComponent(scrobble.track)}`,
|
||||
image: coverFor(scrobble.artist, scrobble.album, scrobble.track)
|
||||
}));
|
||||
}
|
||||
|
||||
function buildSummary(): Summary {
|
||||
const scrobbles = storedScrobbles();
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const dayCounts = countsByDay(scrobbles);
|
||||
const daily = dailyBuckets(dayCounts);
|
||||
const streaks = listeningStreaks(dayCounts);
|
||||
|
||||
return {
|
||||
recentTracks: recentTracks(),
|
||||
daily,
|
||||
monthly: monthlyBuckets(dayCounts),
|
||||
hourly: hourlyBuckets(scrobbles),
|
||||
averagePerDay: Math.round(daily.reduce((total, bucket) => total + bucket.count, 0) / daily.length),
|
||||
currentStreak: streaks.current,
|
||||
longestStreak: streaks.longest,
|
||||
trending: trendingArtists(scrobbles, nowSeconds),
|
||||
top: topChartsByPeriod(scrobbles, nowSeconds),
|
||||
storedScrobbles: scrobbles.length
|
||||
};
|
||||
}
|
||||
|
||||
function tracksMissingCovers(summary: Summary) {
|
||||
return Object.values(summary.top)
|
||||
.flatMap((charts) => charts.tracks)
|
||||
.concat(summary.recentTracks.map((track) => ({ ...track, name: track.track, count: 0 })))
|
||||
.filter((item) => !item.image && item.artist)
|
||||
.map((item) => ({ artist: item.artist!, track: item.name }));
|
||||
}
|
||||
|
||||
function cachedSummary(): Summary {
|
||||
const scrobbleCount = storedScrobbles().length;
|
||||
const currentCoversVersion = coversVersion();
|
||||
const isFresh =
|
||||
summaryCache &&
|
||||
summaryCache.scrobbleCount === scrobbleCount &&
|
||||
summaryCache.coversVersion === currentCoversVersion &&
|
||||
Date.now() - summaryCache.builtAt < summaryDuration;
|
||||
|
||||
if (!isFresh) {
|
||||
const summary = buildSummary();
|
||||
summaryCache = { summary, scrobbleCount, coversVersion: currentCoversVersion, builtAt: Date.now() };
|
||||
requestTrackCovers(tracksMissingCovers(summary));
|
||||
}
|
||||
return summaryCache!.summary;
|
||||
}
|
||||
|
||||
export async function getListeningProfile(userName: string): Promise<ListeningProfile | null> {
|
||||
if (!hasLastfmKey()) return null;
|
||||
|
||||
await Promise.all([loadScrobbleStore(), loadCovers()]);
|
||||
const [info, nowPlaying] = await Promise.all([cachedUserInfo(userName), cachedNowPlaying(userName)]);
|
||||
if (!info && !storedScrobbles().length) return null;
|
||||
|
||||
const summary = cachedSummary();
|
||||
return {
|
||||
userName: info?.name ?? userName,
|
||||
profileUrl: info?.url ?? `https://www.last.fm/user/${userName}`,
|
||||
registeredAt: info?.registeredAt ?? null,
|
||||
totals: {
|
||||
scrobbles: info?.scrobbles ?? summary.storedScrobbles,
|
||||
artists: info?.artists ?? 0,
|
||||
albums: info?.albums ?? 0,
|
||||
tracks: info?.tracks ?? 0
|
||||
},
|
||||
nowPlaying,
|
||||
syncedAt: lastfmSyncedAt(),
|
||||
...summary
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { appendFile, mkdir, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface Scrobble {
|
||||
playedAt: number;
|
||||
artist: string;
|
||||
track: string;
|
||||
album: string;
|
||||
}
|
||||
|
||||
const dataDirectory = path.resolve(process.env.LASTFM_DATA_DIR ?? 'data/lastfm');
|
||||
const scrobblesPath = path.join(dataDirectory, 'scrobbles.jsonl');
|
||||
|
||||
const scrobbles: Scrobble[] = [];
|
||||
let loading: Promise<void> | null = null;
|
||||
|
||||
async function readTextFile(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await readFile(filePath, 'utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFromDisk() {
|
||||
await mkdir(dataDirectory, { recursive: true });
|
||||
|
||||
const lines = (await readTextFile(scrobblesPath)).split('\n').filter(Boolean);
|
||||
scrobbles.push(...lines.map((line) => JSON.parse(line) as Scrobble));
|
||||
scrobbles.sort((first, second) => first.playedAt - second.playedAt);
|
||||
}
|
||||
|
||||
export function loadScrobbleStore(): Promise<void> {
|
||||
loading ??= loadFromDisk();
|
||||
return loading;
|
||||
}
|
||||
|
||||
export function storedScrobbles(): readonly Scrobble[] {
|
||||
return scrobbles;
|
||||
}
|
||||
|
||||
export function latestPlayedAt(): number {
|
||||
return scrobbles.at(-1)?.playedAt ?? 0;
|
||||
}
|
||||
|
||||
export async function appendScrobbles(newScrobbles: Scrobble[]) {
|
||||
const latest = latestPlayedAt();
|
||||
const fresh = newScrobbles
|
||||
.filter((scrobble) => scrobble.playedAt > latest)
|
||||
.sort((first, second) => first.playedAt - second.playedAt);
|
||||
if (!fresh.length) return 0;
|
||||
|
||||
const lines = fresh.map((scrobble) => JSON.stringify(scrobble)).join('\n');
|
||||
await appendFile(scrobblesPath, `${lines}\n`);
|
||||
scrobbles.push(...fresh);
|
||||
return fresh.length;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { PlayedTrack } from '#lib/lastfm/types';
|
||||
import { fetchRecentTracksPage, hasLastfmKey, type RecentTracksPage } from './api';
|
||||
import { appendScrobbles, latestPlayedAt, loadScrobbleStore } from './store';
|
||||
import { loadCovers, rememberAlbumCovers } from './covers';
|
||||
|
||||
const syncInterval = 5 * 60 * 1000;
|
||||
const pageDelay = 300;
|
||||
|
||||
let syncStarted = false;
|
||||
let syncRunning = false;
|
||||
let lastSyncedAt: number | null = null;
|
||||
|
||||
const wait = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
function toScrobble(track: PlayedTrack) {
|
||||
return { playedAt: track.playedAt ?? 0, artist: track.artist, track: track.track, album: track.album };
|
||||
}
|
||||
|
||||
async function savePage(page: RecentTracksPage) {
|
||||
await rememberAlbumCovers(page.tracks);
|
||||
return appendScrobbles(page.tracks.map(toScrobble));
|
||||
}
|
||||
|
||||
async function syncScrobbles(userName: string) {
|
||||
await Promise.all([loadScrobbleStore(), loadCovers()]);
|
||||
const latest = latestPlayedAt();
|
||||
const range = { from: latest ? latest + 1 : undefined, to: Math.floor(Date.now() / 1000) };
|
||||
|
||||
const firstPage = await fetchRecentTracksPage(userName, 1, range);
|
||||
let added = 0;
|
||||
|
||||
for (let pageNumber = firstPage.totalPages; pageNumber > 1; pageNumber--) {
|
||||
added += await savePage(await fetchRecentTracksPage(userName, pageNumber, range));
|
||||
await wait(pageDelay);
|
||||
}
|
||||
added += await savePage(firstPage);
|
||||
|
||||
lastSyncedAt = Date.now();
|
||||
if (added) console.log(`Last.fm stored ${added} new scrobbles`);
|
||||
}
|
||||
|
||||
export function lastfmSyncedAt(): number | null {
|
||||
return lastSyncedAt;
|
||||
}
|
||||
|
||||
export function startLastfmSync(userName: string | undefined) {
|
||||
if (syncStarted || !userName || !hasLastfmKey()) return;
|
||||
syncStarted = true;
|
||||
|
||||
const runSync = async () => {
|
||||
if (syncRunning) return;
|
||||
syncRunning = true;
|
||||
try {
|
||||
await syncScrobbles(userName);
|
||||
} catch (error) {
|
||||
console.error('Last.fm sync failed:', error instanceof Error ? error.message : error);
|
||||
} finally {
|
||||
syncRunning = false;
|
||||
}
|
||||
};
|
||||
|
||||
runSync();
|
||||
setInterval(runSync, syncInterval);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { ChartItem, CountBucket, ListeningPeriod, TopCharts, TrendingArtist } from '#lib/lastfm/types';
|
||||
import { daysBetween, formatDayLabel, localTime, shiftDayKey, shiftMonthKey, todayKey } from './calendar';
|
||||
import { coverFor } from './covers';
|
||||
import type { Scrobble } from './store';
|
||||
|
||||
const secondsPerDay = 24 * 60 * 60;
|
||||
const dailyRange = 30;
|
||||
const monthlyRange = 12;
|
||||
const chartSize = 10;
|
||||
const trendingSize = 8;
|
||||
const periodDays: Record<ListeningPeriod, number | null> = { week: 7, month: 30, year: 365, all: null };
|
||||
|
||||
const musicUrl = (...names: string[]) =>
|
||||
`https://www.last.fm/music/${names.map((name) => encodeURIComponent(name)).join('/_/')}`;
|
||||
const albumUrl = (artist: string, album: string) =>
|
||||
`https://www.last.fm/music/${encodeURIComponent(artist)}/${encodeURIComponent(album)}`;
|
||||
|
||||
function countBy(scrobbles: readonly Scrobble[], keyOf: (scrobble: Scrobble) => string | null) {
|
||||
const counts = new Map<string, { count: number; sample: Scrobble }>();
|
||||
for (const scrobble of scrobbles) {
|
||||
const key = keyOf(scrobble);
|
||||
if (!key) continue;
|
||||
const entry = counts.get(key);
|
||||
if (entry) entry.count++;
|
||||
else counts.set(key, { count: 1, sample: scrobble });
|
||||
}
|
||||
return [...counts.values()].sort((first, second) => second.count - first.count);
|
||||
}
|
||||
|
||||
function scrobblesSince(scrobbles: readonly Scrobble[], days: number | null, nowSeconds: number) {
|
||||
if (days === null) return scrobbles;
|
||||
const cutoff = nowSeconds - days * secondsPerDay;
|
||||
return scrobbles.filter((scrobble) => scrobble.playedAt >= cutoff);
|
||||
}
|
||||
|
||||
function topCharts(scrobbles: readonly Scrobble[]): TopCharts {
|
||||
const artists: ChartItem[] = countBy(scrobbles, (scrobble) => scrobble.artist)
|
||||
.slice(0, chartSize)
|
||||
.map(({ count, sample }) => ({ name: sample.artist, artist: null, count, url: musicUrl(sample.artist), image: null }));
|
||||
|
||||
const albums: ChartItem[] = countBy(scrobbles, (scrobble) => (scrobble.album ? `${scrobble.artist}\n${scrobble.album}` : null))
|
||||
.slice(0, chartSize)
|
||||
.map(({ count, sample }) => ({
|
||||
name: sample.album,
|
||||
artist: sample.artist,
|
||||
count,
|
||||
url: albumUrl(sample.artist, sample.album),
|
||||
image: coverFor(sample.artist, sample.album)
|
||||
}));
|
||||
|
||||
const tracks: ChartItem[] = countBy(scrobbles, (scrobble) => `${scrobble.artist}\n${scrobble.track}`)
|
||||
.slice(0, chartSize)
|
||||
.map(({ count, sample }) => ({
|
||||
name: sample.track,
|
||||
artist: sample.artist,
|
||||
count,
|
||||
url: musicUrl(sample.artist, sample.track),
|
||||
image: coverFor(sample.artist, sample.album, sample.track)
|
||||
}));
|
||||
|
||||
return { artists, albums, tracks };
|
||||
}
|
||||
|
||||
export function topChartsByPeriod(scrobbles: readonly Scrobble[], nowSeconds: number): Record<ListeningPeriod, TopCharts> {
|
||||
const entries = Object.entries(periodDays).map(([period, days]) => [
|
||||
period,
|
||||
topCharts(scrobblesSince(scrobbles, days, nowSeconds))
|
||||
]);
|
||||
return Object.fromEntries(entries) as Record<ListeningPeriod, TopCharts>;
|
||||
}
|
||||
|
||||
export function trendingArtists(scrobbles: readonly Scrobble[], nowSeconds: number): TrendingArtist[] {
|
||||
const weekStart = nowSeconds - 7 * secondsPerDay;
|
||||
const previousWeekStart = weekStart - 7 * secondsPerDay;
|
||||
const thisWeek = scrobbles.filter((scrobble) => scrobble.playedAt >= weekStart);
|
||||
const previousWeek = scrobbles.filter(
|
||||
(scrobble) => scrobble.playedAt >= previousWeekStart && scrobble.playedAt < weekStart
|
||||
);
|
||||
const previousCounts = new Map(countBy(previousWeek, (scrobble) => scrobble.artist).map((entry) => [entry.sample.artist, entry.count]));
|
||||
|
||||
return countBy(thisWeek, (scrobble) => scrobble.artist)
|
||||
.slice(0, trendingSize)
|
||||
.map(({ count, sample }) => ({
|
||||
name: sample.artist,
|
||||
url: musicUrl(sample.artist),
|
||||
count,
|
||||
previousCount: previousCounts.get(sample.artist) ?? 0
|
||||
}));
|
||||
}
|
||||
|
||||
export function countsByDay(scrobbles: readonly Scrobble[]): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const scrobble of scrobbles) {
|
||||
const { dayKey } = localTime(scrobble.playedAt);
|
||||
counts.set(dayKey, (counts.get(dayKey) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function dailyBuckets(dayCounts: Map<string, number>): CountBucket[] {
|
||||
const today = todayKey();
|
||||
return Array.from({ length: dailyRange }, (_, index) => {
|
||||
const dayKey = shiftDayKey(today, index - dailyRange + 1);
|
||||
return {
|
||||
label: formatDayLabel(dayKey, { day: 'numeric' }),
|
||||
title: formatDayLabel(dayKey, { weekday: 'short', month: 'short', day: 'numeric' }),
|
||||
count: dayCounts.get(dayKey) ?? 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function monthlyBuckets(dayCounts: Map<string, number>): CountBucket[] {
|
||||
const monthCounts = new Map<string, number>();
|
||||
for (const [dayKey, count] of dayCounts) {
|
||||
const monthKey = dayKey.slice(0, 7);
|
||||
monthCounts.set(monthKey, (monthCounts.get(monthKey) ?? 0) + count);
|
||||
}
|
||||
|
||||
const currentMonth = todayKey().slice(0, 7);
|
||||
return Array.from({ length: monthlyRange }, (_, index) => {
|
||||
const monthKey = shiftMonthKey(currentMonth, index - monthlyRange + 1);
|
||||
return {
|
||||
label: formatDayLabel(`${monthKey}-01`, { month: 'narrow' }),
|
||||
title: formatDayLabel(`${monthKey}-01`, { month: 'long', year: 'numeric' }),
|
||||
count: monthCounts.get(monthKey) ?? 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function hourlyBuckets(scrobbles: readonly Scrobble[]): CountBucket[] {
|
||||
const counts = Array<number>(24).fill(0);
|
||||
for (const scrobble of scrobbles) counts[localTime(scrobble.playedAt).hour]++;
|
||||
|
||||
return counts.map((count, hour) => {
|
||||
const hourLabel = `${hour % 12 || 12}${hour < 12 ? 'am' : 'pm'}`;
|
||||
return { label: hour % 6 === 0 ? hourLabel : '', title: hourLabel, count };
|
||||
});
|
||||
}
|
||||
|
||||
export function listeningStreaks(dayCounts: Map<string, number>): { current: number; longest: number } {
|
||||
const today = todayKey();
|
||||
let current = 0;
|
||||
let cursor = dayCounts.has(today) ? today : shiftDayKey(today, -1);
|
||||
while (dayCounts.has(cursor)) {
|
||||
current++;
|
||||
cursor = shiftDayKey(cursor, -1);
|
||||
}
|
||||
|
||||
let longest = 0;
|
||||
let running = 0;
|
||||
let previousKey: string | null = null;
|
||||
for (const dayKey of [...dayCounts.keys()].sort()) {
|
||||
running = previousKey && daysBetween(previousKey, dayKey) === 1 ? running + 1 : 1;
|
||||
longest = Math.max(longest, running);
|
||||
previousKey = dayKey;
|
||||
}
|
||||
|
||||
return { current, longest };
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { about } from '#lib/content';
|
||||
import { getAnimeProfile } from '#lib/server/anilist';
|
||||
import { getMediaDetails } from '#lib/server/omdb';
|
||||
import { getListeningProfile } from '#lib/server/lastfm/profile';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const mediaUrls = [...about.shows, ...about.movies].map((item) => item.url);
|
||||
|
||||
const [anime, mediaDetails] = await Promise.all([
|
||||
const [anime, mediaDetails, listening] = await Promise.all([
|
||||
about.anilistUsername ? getAnimeProfile(about.anilistUsername) : null,
|
||||
getMediaDetails(mediaUrls)
|
||||
getMediaDetails(mediaUrls),
|
||||
about.lastfmUsername ? getListeningProfile(about.lastfmUsername) : null
|
||||
]);
|
||||
|
||||
return { anime, mediaDetails };
|
||||
return { anime, mediaDetails, listening };
|
||||
};
|
||||
@@ -8,6 +8,7 @@
|
||||
import AnimeSection from '#lib/components/about/AnimeSection.svelte';
|
||||
import MediaGrid from '#lib/components/about/MediaGrid.svelte';
|
||||
import GameCard from '#lib/components/about/GameCard.svelte';
|
||||
import ListeningSection from '#lib/components/about/listening/ListeningSection.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
@@ -25,6 +26,7 @@
|
||||
{ id: 'games', label: 'Games', color: 'orange' },
|
||||
{ id: 'shows', label: 'Shows', color: 'yellow' },
|
||||
{ id: 'movies', label: 'Movies', color: 'green' },
|
||||
...(data.listening ? [{ id: 'music', label: 'Music', color: 'red' }] : []),
|
||||
...(data.anime ? [{ id: 'anime', label: 'Anime', color: 'magenta' }] : [])
|
||||
]);
|
||||
</script>
|
||||
@@ -59,6 +61,12 @@
|
||||
</Section>
|
||||
{/each}
|
||||
|
||||
{#if data.listening}
|
||||
<Section title="Music" id="music">
|
||||
<ListeningSection listening={data.listening} />
|
||||
</Section>
|
||||
{/if}
|
||||
|
||||
{#if data.anime}
|
||||
<Section title="Anime" id="anime">
|
||||
<AnimeSection anime={data.anime} />
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { loadCovers, savedCoverPath } from '#lib/server/lastfm/covers';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const contentTypes: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif'
|
||||
};
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
await loadCovers();
|
||||
const filePath = savedCoverPath(params.id);
|
||||
if (!filePath) error(404, 'Cover not found');
|
||||
|
||||
return new Response(await readFile(filePath), {
|
||||
headers: {
|
||||
'Content-Type': contentTypes[path.extname(filePath)] ?? 'image/jpeg',
|
||||
'Cache-Control': 'public, max-age=31536000, immutable'
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user