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
@@ -2,3 +2,4 @@ DISCORD_TOKEN=
|
|||||||
DISCORD_USER_ID=
|
DISCORD_USER_ID=
|
||||||
DISCORD_GUILD_ID=
|
DISCORD_GUILD_ID=
|
||||||
OMDB_API_KEY=
|
OMDB_API_KEY=
|
||||||
|
LASTFM_API_KEY=
|
||||||
@@ -25,6 +25,7 @@ vite.config.ts.timestamp-*
|
|||||||
bun.lock
|
bun.lock
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.cache/
|
.cache/
|
||||||
|
data/
|
||||||
Update.md
|
Update.md
|
||||||
PROJECT.md
|
PROJECT.md
|
||||||
blogs/
|
blogs/
|
||||||
+1
-1
@@ -18,7 +18,7 @@ COPY package.json ./
|
|||||||
RUN bun install --production --ignore-scripts
|
RUN bun install --production --ignore-scripts
|
||||||
|
|
||||||
COPY --from=build /app/build ./build
|
COPY --from=build /app/build ./build
|
||||||
RUN mkdir -p /app/.cache/music && chown -R bun:bun /app/.cache
|
RUN mkdir -p /app/.cache/music /app/data/lastfm && chown -R bun:bun /app/.cache /app/data
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"anilistUsername": "SirBlob",
|
"anilistUsername": "SirBlob",
|
||||||
|
"lastfmUsername": "Sir_Blob_",
|
||||||
"hobbies": [
|
"hobbies": [
|
||||||
{ "name": "Swimming", "icon": "proicons:person-swim", "description": "I enjoy swimming as a way to relax and stay active. It is my favorite sport." },
|
{ "name": "Swimming", "icon": "proicons:person-swim", "description": "I enjoy swimming as a way to relax and stay active. It is my favorite sport." },
|
||||||
{ "name": "Baking", "icon": "proicons:cake", "description": "I love baking pastries and experimenting with new dessert recipes." },
|
{ "name": "Baking", "icon": "proicons:cake", "description": "I love baking pastries and experimenting with new dessert recipes." },
|
||||||
|
|||||||
@@ -9,3 +9,8 @@ services:
|
|||||||
PORT: 3000
|
PORT: 3000
|
||||||
ports:
|
ports:
|
||||||
- "${HOST_PORT:-3000}:3000"
|
- "${HOST_PORT:-3000}:3000"
|
||||||
|
volumes:
|
||||||
|
- lastfm-data:/app/data/lastfm
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
lastfm-data:
|
||||||
@@ -18,5 +18,9 @@ export const variables = defineEnvVars({
|
|||||||
OMDB_API_KEY: {
|
OMDB_API_KEY: {
|
||||||
schema: optional,
|
schema: optional,
|
||||||
description: 'OMDb API key used to load posters and details for shows and movies'
|
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 type { ServerInit } from '@sveltejs/kit';
|
||||||
import { getDiscordClient } from '#lib/server/discord';
|
import { getDiscordClient } from '#lib/server/discord';
|
||||||
import { refreshMusicCache } from '#lib/server/musicCache';
|
import { refreshMusicCache } from '#lib/server/musicCache';
|
||||||
|
import { startLastfmSync } from '#lib/server/lastfm/sync';
|
||||||
|
import { about } from '#lib/content';
|
||||||
|
|
||||||
export const init: ServerInit = () => {
|
export const init: ServerInit = () => {
|
||||||
getDiscordClient();
|
getDiscordClient();
|
||||||
refreshMusicCache();
|
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 {
|
export interface AboutContent {
|
||||||
anilistUsername?: string;
|
anilistUsername?: string;
|
||||||
|
lastfmUsername?: string;
|
||||||
hobbies: Hobby[];
|
hobbies: Hobby[];
|
||||||
games: Game[];
|
games: Game[];
|
||||||
shows: RatedTitle[];
|
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 { about } from '#lib/content';
|
||||||
import { getAnimeProfile } from '#lib/server/anilist';
|
import { getAnimeProfile } from '#lib/server/anilist';
|
||||||
import { getMediaDetails } from '#lib/server/omdb';
|
import { getMediaDetails } from '#lib/server/omdb';
|
||||||
|
import { getListeningProfile } from '#lib/server/lastfm/profile';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
const mediaUrls = [...about.shows, ...about.movies].map((item) => item.url);
|
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,
|
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 AnimeSection from '#lib/components/about/AnimeSection.svelte';
|
||||||
import MediaGrid from '#lib/components/about/MediaGrid.svelte';
|
import MediaGrid from '#lib/components/about/MediaGrid.svelte';
|
||||||
import GameCard from '#lib/components/about/GameCard.svelte';
|
import GameCard from '#lib/components/about/GameCard.svelte';
|
||||||
|
import ListeningSection from '#lib/components/about/listening/ListeningSection.svelte';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
{ id: 'games', label: 'Games', color: 'orange' },
|
{ id: 'games', label: 'Games', color: 'orange' },
|
||||||
{ id: 'shows', label: 'Shows', color: 'yellow' },
|
{ id: 'shows', label: 'Shows', color: 'yellow' },
|
||||||
{ id: 'movies', label: 'Movies', color: 'green' },
|
{ id: 'movies', label: 'Movies', color: 'green' },
|
||||||
|
...(data.listening ? [{ id: 'music', label: 'Music', color: 'red' }] : []),
|
||||||
...(data.anime ? [{ id: 'anime', label: 'Anime', color: 'magenta' }] : [])
|
...(data.anime ? [{ id: 'anime', label: 'Anime', color: 'magenta' }] : [])
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
@@ -59,6 +61,12 @@
|
|||||||
</Section>
|
</Section>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
{#if data.listening}
|
||||||
|
<Section title="Music" id="music">
|
||||||
|
<ListeningSection listening={data.listening} />
|
||||||
|
</Section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if data.anime}
|
{#if data.anime}
|
||||||
<Section title="Anime" id="anime">
|
<Section title="Anime" id="anime">
|
||||||
<AnimeSection anime={data.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