Compare commits

...
8 Commits
55 changed files with 2128 additions and 131 deletions

No files matched your search

+1
View File
@@ -15,6 +15,7 @@ UPDATE.md
blogs
tools
__pycache__
.cache
*.log
.DS_Store
Thumbs.db
+1
View File
@@ -2,3 +2,4 @@ DISCORD_TOKEN=
DISCORD_USER_ID=
DISCORD_GUILD_ID=
OMDB_API_KEY=
LASTFM_API_KEY=
+2
View File
@@ -24,6 +24,8 @@ vite.config.ts.timestamp-*
bun.lock
__pycache__/
.cache/
data/
Update.md
PROJECT.md
blogs/
+1
View File
@@ -18,6 +18,7 @@ COPY package.json ./
RUN bun install --production --ignore-scripts
COPY --from=build /app/build ./build
RUN mkdir -p /app/.cache/music /app/data/lastfm && chown -R bun:bun /app/.cache /app/data
ENV NODE_ENV=production
ENV PORT=3000
+1
View File
@@ -1,5 +1,6 @@
{
"anilistUsername": "SirBlob",
"lastfmUsername": "Sir_Blob_",
"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": "Baking", "icon": "proicons:cake", "description": "I love baking pastries and experimenting with new dessert recipes." },
+1
View File
@@ -6,6 +6,7 @@
"title": "Computer Science Student",
"email": "sirblob0@gmail.com",
"donateUrl": "https://ko-fi.com/sirblob",
"giteaUrl": "https://git.sirblob.co/SirBlob",
"avatar": "/blob_nerd.png",
"photo": "/portfolio-photo.jpg",
"bio": "An engineer who loves making things. I build fun coding projects, compete in hackathons and robotics competitions, and enjoy games like Minecraft and Pokémon TCG Live. I'm interested in Open Source, Robotics, Embedded Systems, and AI/ML.",
+4 -6
View File
@@ -7,7 +7,7 @@
"featured": true,
"event": { "name": "Bitcamp 2026", "university": "University of Maryland", "location": "College Park, MD", "year": "2026" },
"awards": [{ "track": "Best UI/UX Hack", "place": "Winner" }],
"links": { "demo": "https://hushmap.study/", "devpost": "https://devpost.com/software/hushmap-193nia" }
"links": { "devpost": "https://devpost.com/software/hushmap-193nia" }
},
{
"name": "LearningBuddy",
@@ -53,9 +53,8 @@
"description": "Ever felt the indecisiveness of choosing what to eat? Don't fret! Decide your next bite with Food Decisive.",
"image": "/hacks/fooddecisive.png",
"tags": ["AI", "Llama3.1", "Svelte", "Node.js"],
"demoMayBeOffline": true,
"event": { "name": "VTHacks 12", "university": "Virginia Tech", "location": "Blacksburg, VA", "year": "2024" },
"links": { "demo": "https://fooddecisive.sirblob.co/", "code": "https://github.com/SirBlobby/VTHacks-12", "devpost": "https://devpost.com/software/food-decisive" }
"links": { "code": "https://github.com/SirBlobby/VTHacks-12", "devpost": "https://devpost.com/software/food-decisive" }
},
{
"name": "PatSafe",
@@ -79,15 +78,14 @@
"image": "/hacks/drinkhappy.png",
"tags": ["API", "Auth0", "Gemini", "JavaScript", "MongoDB", "Next.js", "React", "Tailwind", "Vercel"],
"event": { "name": "Bitcamp 2025", "university": "University of Maryland", "location": "College Park, MD", "year": "2025" },
"links": { "demo": "https://drinkhappy.tech", "code": "https://github.com/SirBlobby/Bitcamp-2025", "devpost": "https://devpost.com/software/drink-happy" }
"links": { "code": "https://github.com/SirBlobby/Bitcamp-2025", "devpost": "https://devpost.com/software/drink-happy" }
},
{
"name": "Roadcast",
"description": "Roadcast provides drivers with crash data and weather insights to choose safer routes home. The project combines historical crash data, weather feeds, and spatial analysis to surface hazardous areas and route-level safety recommendations.",
"image": "/hacks/roadcast.png",
"tags": ["React", "Node.js", "Python", "MongoDB", "Geospatial", "Mapbox"],
"demoMayBeOffline": true,
"event": { "name": "VTHacks 13", "university": "Virginia Tech", "location": "Blacksburg, VA", "year": "2025" },
"links": { "demo": "https://roadcast.sirblob.co/", "code": "https://github.com/SirBlobby/roadcast", "devpost": "https://devpost.com/software/roadcast" }
"links": { "code": "https://github.com/SirBlobby/roadcast", "devpost": "https://devpost.com/software/roadcast" }
}
]
+5
View File
@@ -9,3 +9,8 @@ services:
PORT: 3000
ports:
- "${HOST_PORT:-3000}:3000"
volumes:
- lastfm-data:/app/data/lastfm
volumes:
lastfm-data:
+4
View File
@@ -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'
}
});
+5
View File
@@ -1,6 +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);
};
+5
View File
@@ -2,10 +2,15 @@ export interface ContributionDay {
date: string;
level: number;
count: number;
githubCount: number;
giteaCount: number;
}
export interface ContributionCalendar {
total: number;
githubTotal: number;
giteaTotal: number;
giteaProfileUrl: string | null;
weeks: ContributionDay[][];
currentStreak: number;
bestDay: ContributionDay | null;
@@ -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>
@@ -1,12 +1,23 @@
<script lang="ts">
import type { BtopData } from '#lib/btop/types';
import type { BtopData, ContributionDay } from '#lib/btop/types';
import BtopBox from './BtopBox.svelte';
let { data }: { data: BtopData | null } = $props();
const levelOpacity = [0, 0.3, 0.55, 0.8, 1];
const levelOpacity = [1, 0.35, 0.6, 0.8, 1];
const calendar = $derived(data?.contributions ?? null);
function dayBackground({ githubCount, giteaCount }: ContributionDay): string | undefined {
if (githubCount > 0 && giteaCount > 0) return 'linear-gradient(135deg, var(--green) 50%, var(--orange) 50%)';
if (giteaCount > 0) return 'var(--orange)';
if (githubCount > 0) return 'var(--green)';
return undefined;
}
function dayTitle(day: ContributionDay): string {
return `${day.count} contributions on ${day.date} (GitHub ${day.githubCount}, Gitea ${day.giteaCount})`;
}
</script>
<BtopBox title="net · commits" number={3} color="blue">
@@ -19,27 +30,38 @@
{/if}
</div>
<a class="heatmap" href={data.github.profileUrl} target="_blank" rel="noopener noreferrer" aria-label="GitHub contribution graph">
<div class="heatmap" aria-label="Contribution graph">
{#each calendar.weeks as week, weekIndex (weekIndex)}
<span class="week">
{#each week as day (day.date)}
<span
class="day"
style:opacity={day.level === 0 ? 1 : levelOpacity[day.level]}
class:empty={day.level === 0}
title="{day.count} contributions on {day.date}"
class:empty={day.count === 0}
style:background={dayBackground(day)}
style:opacity={levelOpacity[day.level]}
title={dayTitle(day)}
></span>
{/each}
</span>
{/each}
</a>
</div>
<div class="legend muted">
<div class="legend">
<a class="source" href={data.github.profileUrl} target="_blank" rel="noopener noreferrer">
<span class="swatch github"></span>GitHub <b>{calendar.githubTotal.toLocaleString('en-US')}</b>
</a>
{#if calendar.giteaProfileUrl}
<a class="source" href={calendar.giteaProfileUrl} target="_blank" rel="noopener noreferrer">
<span class="swatch gitea"></span>Gitea <b>{calendar.giteaTotal.toLocaleString('en-US')}</b>
</a>
{/if}
<span class="scale muted">
less
{#each levelOpacity as opacity, level (level)}
<span class="day" class:empty={level === 0} style:opacity={level === 0 ? 1 : opacity}></span>
<span class="swatch" class:empty={level === 0} style:opacity></span>
{/each}
more
</span>
</div>
{:else}
<p class="muted loading">connecting to github...</p>
@@ -76,25 +98,57 @@
display: block;
aspect-ratio: 1;
min-width: 4px;
background: var(--green);
border-radius: 1px;
}
.day.empty {
.empty {
background: color-mix(in srgb, var(--foreground) 10%, transparent);
}
.legend {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 3px;
margin-top: 0.4rem;
font-size: 0.7rem;
gap: 0.3rem 0.9rem;
margin-top: 0.5rem;
font-size: 0.72rem;
}
.legend .day {
.source {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.source:hover {
color: var(--accent);
}
.source b {
color: var(--blue);
}
.scale {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: auto;
}
.swatch {
display: inline-block;
width: 9px;
height: 9px;
border-radius: 1px;
background: var(--green);
}
.swatch.gitea {
background: var(--orange);
}
.swatch.empty {
background: color-mix(in srgb, var(--foreground) 10%, transparent);
}
.loading {
+1 -51
View File
@@ -1,22 +1,18 @@
<script lang="ts">
import { onMount } from 'svelte';
import { profile } from '#lib/content';
import { randomWalk, usageTone } from '#lib/btop/simulate';
import BtopBox from './BtopBox.svelte';
const historyLength = 48;
const updateInterval = 1000;
const coreNames = (profile.skills.Languages ?? []).slice(0, 6);
let history = $state<number[]>(Array.from({ length: historyLength }, () => 20 + Math.random() * 30));
let cores = $state(coreNames.map((name) => ({ name, usage: 20 + Math.random() * 60 })));
const totalUsage = $derived(Math.round(history.at(-1) ?? 0));
onMount(() => {
const timer = setInterval(() => {
history = [...history.slice(1), randomWalk(history.at(-1) ?? 40, 18, 5, 98)];
cores = cores.map((core) => ({ ...core, usage: randomWalk(core.usage, 15, 3, 100) }));
}, updateInterval);
return () => clearInterval(timer);
});
@@ -33,16 +29,6 @@
<span class="bar" style:height="{value}%" style:background="var(--{usageTone(value)})"></span>
{/each}
</div>
<div class="cores">
{#each cores as core (core.name)}
<div class="core">
<span class="core-name">{core.name}</span>
<span class="core-meter"><span style:width="{core.usage}%" style:background="var(--{usageTone(core.usage)})"></span></span>
<span class="core-value">{Math.round(core.usage)}%</span>
</div>
{/each}
</div>
</BtopBox>
<style>
@@ -69,7 +55,7 @@
align-items: flex-end;
gap: 1px;
height: 70px;
margin: 0.4rem 0 0.6rem;
margin-top: 0.4rem;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
}
@@ -79,40 +65,4 @@
opacity: 0.85;
transition: height 0.4s ease;
}
.cores {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 0.2rem 1rem;
font-size: 0.72rem;
}
.core {
display: grid;
grid-template-columns: 6.5rem 1fr 2.5rem;
align-items: center;
gap: 0.4rem;
}
.core-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.core-meter {
height: 6px;
background: color-mix(in srgb, var(--foreground) 10%, transparent);
}
.core-meter span {
display: block;
height: 100%;
transition: width 0.4s ease;
}
.core-value {
color: var(--muted);
text-align: right;
}
</style>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { focusTrap } from '#lib/ui/focusTrap';
import Icon from '@iconify/svelte';
import { musicPlayer } from '#lib/music/player.svelte';
import MusicNowPlaying from './MusicNowPlaying.svelte';
@@ -19,7 +20,7 @@
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close music player" onclick={onclose}></button>
<div class="popover" role="dialog" aria-label="Music player">
<div class="popover" role="dialog" aria-modal="true" aria-label="Music player" tabindex="-1" use:focusTrap>
<div class="popover-surface">
{#if track}
<MusicNowPlaying {track} />
@@ -1,4 +1,5 @@
<script lang="ts">
import { focusTrap } from '#lib/ui/focusTrap';
import Icon from '@iconify/svelte';
import { slide } from 'svelte/transition';
import {
@@ -41,7 +42,7 @@
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close battery details" onclick={onclose}></button>
<div class="popover" role="dialog" aria-label="Battery details">
<div class="popover" role="dialog" aria-modal="true" aria-label="Battery details" tabindex="-1" use:focusTrap>
<div class="popover-surface" class:charging={battery.charging} class:low={isLow}>
<header class="summary">
<span class="battery-icon">
@@ -1,4 +1,5 @@
<script lang="ts">
import { focusTrap } from '#lib/ui/focusTrap';
import Icon from '@iconify/svelte';
import { activityLabels, type DiscordPresence } from '#lib/discord/types';
@@ -12,7 +13,7 @@
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close Discord status" onclick={onclose}></button>
<div class="popover" role="dialog" aria-label="Discord status">
<div class="popover" role="dialog" aria-modal="true" aria-label="Discord status" tabindex="-1" use:focusTrap>
<div class="popover-surface">
<header class="profile">
<div class="avatar">
@@ -1,4 +1,5 @@
<script lang="ts">
import { focusTrap } from '#lib/ui/focusTrap';
import Icon from '@iconify/svelte';
let { donateUrl, discordUrl, onclose }: { donateUrl: string; discordUrl: string; onclose: () => void } = $props();
@@ -11,7 +12,7 @@
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close donate card" onclick={onclose}></button>
<div class="popover" role="dialog" aria-label="Support me">
<div class="popover" role="dialog" aria-modal="true" aria-label="Support me" tabindex="-1" use:focusTrap>
<div class="popover-surface">
<header class="header">
<Icon icon="simple-icons:kofi" width="22" />
+1
View File
@@ -18,6 +18,7 @@
{#if track}
{#key musicPlayer.streamKey}
<audio
bind:this={musicPlayer.audioElement}
src={musicPlayer.streamUrl}
autoplay
preload="auto"
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { focusTrap } from '#lib/ui/focusTrap';
import type { Snippet } from 'svelte';
let { title, onclose, children }: { title: string; onclose: () => void; children: Snippet } = $props();
@@ -11,7 +12,7 @@
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close {title}" onclick={onclose}></button>
<div class="popup" role="dialog" aria-label={title}>
<div class="popup" role="dialog" aria-modal="true" aria-label={title} tabindex="-1" use:focusTrap>
<div class="popup-title">{title}</div>
<div class="popup-body">
{@render children()}
+21 -10
View File
@@ -1,19 +1,30 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { page } from '$app/state';
import { profile } from '#lib/content';
import { findSitePage } from '#lib/site/pages';
import PageMeta, { type PageMetaOptions } from './PageMeta.svelte';
let { command, title, subtitle, children }: { command: string; title?: string; subtitle?: string; children?: Snippet } =
$props();
let {
command,
title,
subtitle,
description,
meta,
children
}: {
command: string;
title?: string;
subtitle?: string;
description?: string;
meta?: PageMetaOptions;
children?: Snippet;
} = $props();
const metaDescription = $derived(description ?? subtitle ?? findSitePage(page.url.pathname)?.description);
</script>
<svelte:head>
{#if title}
<title>{title} | {profile.displayName}</title>
{/if}
{#if subtitle}
<meta name="description" content={subtitle} />
{/if}
</svelte:head>
<PageMeta {title} description={metaDescription} {...meta} />
<header class="page-header">
<div class="command">
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts" module>
export type PageMetaOptions = {
image?: string;
type?: 'website' | 'article' | 'profile';
publishedTime?: string;
tags?: string[];
noIndex?: boolean;
};
</script>
<script lang="ts">
import { page } from '$app/state';
import { profile } from '#lib/content';
let {
title,
description,
image,
type = 'website',
publishedTime,
tags = [],
noIndex = false
}: PageMetaOptions & { title?: string; description?: string } = $props();
const fullTitle = $derived(title ? `${title} | ${profile.displayName}` : `${profile.displayName} | Portfolio`);
const fullDescription = $derived(description ?? `${profile.title} - ${profile.bio}`);
const pageUrl = $derived(`${page.url.origin}${page.url.pathname}`);
const imageUrl = $derived(new URL(image ?? profile.photo ?? profile.avatar, page.url.origin).href);
</script>
<svelte:head>
<title>{fullTitle}</title>
<meta name="description" content={fullDescription} />
<link rel="canonical" href={pageUrl} />
{#if noIndex}
<meta name="robots" content="noindex" />
{/if}
<meta property="og:site_name" content="{profile.displayName}'s Desktop" />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={fullDescription} />
<meta property="og:url" content={pageUrl} />
<meta property="og:type" content={type} />
<meta property="og:image" content={imageUrl} />
<meta property="og:image:alt" content={profile.displayName} />
{#if type === 'article' && publishedTime}
<meta property="article:published_time" content={publishedTime} />
<meta property="article:author" content={profile.name} />
{#each tags as tag (tag)}
<meta property="article:tag" content={tag} />
{/each}
{/if}
{#if type === 'profile'}
<meta property="profile:username" content={profile.username} />
{/if}
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={fullDescription} />
<meta name="twitter:image" content={imageUrl} />
</svelte:head>
+2
View File
@@ -18,6 +18,7 @@ export interface Profile {
title: string;
email: string;
donateUrl?: string;
giteaUrl?: string;
avatar: string;
photo?: string;
bio: string;
@@ -138,6 +139,7 @@ export interface RatedTitle {
export interface AboutContent {
anilistUsername?: string;
lastfmUsername?: string;
hobbies: Hobby[];
games: Game[];
shows: RatedTitle[];
+63
View File
@@ -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;
}
+9 -1
View File
@@ -14,6 +14,8 @@ class MusicPlayer {
history = $state<number[]>([]);
repeat = $state(false);
audioElement: HTMLAudioElement | null = null;
private failedTrackCount = 0;
private loading = false;
@@ -108,7 +110,13 @@ class MusicPlayer {
}
seek(seconds: number) {
this.startOffset = Math.max(0, Math.min(Math.floor(seconds), this.duration - 1));
const targetSeconds = Math.max(0, Math.min(Math.floor(seconds), this.duration - 1));
if (this.currentTrack?.cached && this.audioElement) {
this.audioElement.currentTime = targetSeconds;
this.paused = false;
return;
}
this.startOffset = targetSeconds;
this.elapsedSinceStart = 0;
this.streamKey += 1;
this.paused = false;
+1
View File
@@ -4,4 +4,5 @@ export interface Track {
artist: string;
durationSeconds: number;
cover: string;
cached: boolean;
}
+17 -3
View File
@@ -1,6 +1,7 @@
import { profile } from '#lib/content';
import type { BtopData, GithubSummary, RepoProcess } from '#lib/btop/types';
import { fetchContributionCalendar } from './contributions';
import { buildCalendar, fetchGithubCalendar } from './contributions';
import { fetchGiteaDailyCounts, parseGiteaProfile } from './gitea';
const cacheDuration = 60 * 60 * 1000;
const githubHeaders = { Accept: 'application/vnd.github+json', 'User-Agent': 'portfolio-website' };
@@ -36,6 +37,17 @@ async function fetchSummary(username: string): Promise<GithubSummary> {
return { username, profileUrl: user.html_url, publicRepos: user.public_repos, followers: user.followers };
}
async function fetchGiteaCounts(): Promise<Map<string, number>> {
const gitea = parseGiteaProfile(profile.giteaUrl);
if (!gitea) return new Map();
try {
return await fetchGiteaDailyCounts(gitea);
} catch (error) {
console.error('Gitea heatmap lookup failed:', error);
return new Map();
}
}
async function fetchRepos(username: string): Promise<RepoProcess[]> {
const repos = await fetchJson<GithubRepo[]>(`https://api.github.com/users/${username}/repos?per_page=100&sort=pushed`);
return repos
@@ -58,11 +70,13 @@ export async function getBtopData(): Promise<BtopData | null> {
if (!username) return null;
try {
const [github, contributions, repos] = await Promise.all([
const [github, githubCalendar, giteaCounts, repos] = await Promise.all([
fetchSummary(username),
fetchContributionCalendar(username),
fetchGithubCalendar(username),
fetchGiteaCounts(),
fetchRepos(username)
]);
const contributions = buildCalendar(githubCalendar, giteaCounts, profile.giteaUrl ?? null);
const data = { github, contributions, repos };
cached = { data, fetchedAt: Date.now() };
return data;
+60 -15
View File
@@ -3,6 +3,12 @@ import type { ContributionCalendar, ContributionDay } from '#lib/btop/types';
const dayCellPattern = /data-date="(\d{4}-\d{2}-\d{2})" id="contribution-day-component-(\d+)-(\d+)" data-level="(\d)"/g;
const tooltipPattern = /for="contribution-day-component-(\d+-\d+)"[^>]*>(\d+|No) contributions? on/g;
const totalPattern = /([\d,]+)\s+contributions?\s+in the last year/;
const levelCount = 4;
interface GithubCalendar {
weeks: ContributionDay[][];
total: number;
}
function parseCounts(html: string): Map<string, number> {
const counts = new Map<string, number>();
@@ -15,14 +21,40 @@ function parseCounts(html: string): Map<string, number> {
function parseWeeks(html: string): ContributionDay[][] {
const counts = parseCounts(html);
const weeks: ContributionDay[][] = [];
for (const [, date, weekday, week, level] of html.matchAll(dayCellPattern)) {
const weekIndex = Number(week);
weeks[weekIndex] ??= [];
weeks[weekIndex][Number(weekday)] = { date, level: Number(level), count: counts.get(`${weekday}-${week}`) ?? 0 };
for (const [, date, weekday, week] of html.matchAll(dayCellPattern)) {
const githubCount = counts.get(`${weekday}-${week}`) ?? 0;
weeks[Number(week)] ??= [];
weeks[Number(week)][Number(weekday)] = { date, level: 0, count: githubCount, githubCount, giteaCount: 0 };
}
return weeks.map((days) => days.filter(Boolean));
}
export async function fetchGithubCalendar(username: string): Promise<GithubCalendar> {
const response = await fetch(`https://github.com/users/${username}/contributions`, {
headers: { 'User-Agent': 'portfolio-website' }
});
if (!response.ok) throw new Error(`GitHub contributions returned ${response.status}`);
const html = await response.text();
const weeks = parseWeeks(html);
const summedTotal = weeks.flat().reduce((sum, day) => sum + day.githubCount, 0);
return { weeks, total: Number(html.match(totalPattern)?.[1].replace(/,/g, '') ?? summedTotal) };
}
function levelThresholds(counts: number[]): number[] {
const activeCounts = counts.filter((count) => count > 0).sort((first, second) => first - second);
if (activeCounts.length === 0) return [];
return Array.from({ length: levelCount - 1 }, (_, index) => {
const position = Math.floor(((index + 1) / levelCount) * activeCounts.length);
return activeCounts[Math.min(position, activeCounts.length - 1)];
});
}
function levelFor(count: number, thresholds: number[]): number {
if (count === 0) return 0;
return 1 + thresholds.filter((threshold) => count > threshold).length;
}
function currentStreak(days: ContributionDay[]): number {
const today = new Date().toISOString().slice(0, 10);
const pastDays = days.filter((day) => day.date <= today);
@@ -33,17 +65,30 @@ function currentStreak(days: ContributionDay[]): number {
return streak;
}
export async function fetchContributionCalendar(username: string): Promise<ContributionCalendar> {
const response = await fetch(`https://github.com/users/${username}/contributions`, {
headers: { 'User-Agent': 'portfolio-website' }
});
if (!response.ok) throw new Error(`GitHub contributions returned ${response.status}`);
const html = await response.text();
const weeks = parseWeeks(html);
const days = weeks.flat().sort((first, second) => first.date.localeCompare(second.date));
const total = Number(html.match(totalPattern)?.[1].replace(/,/g, '') ?? days.reduce((sum, day) => sum + day.count, 0));
export function buildCalendar(
github: GithubCalendar,
giteaCounts: Map<string, number>,
giteaProfileUrl: string | null
): ContributionCalendar {
const mergedWeeks = github.weeks.map((week) =>
week.map((day) => {
const giteaCount = giteaCounts.get(day.date) ?? 0;
return { ...day, giteaCount, count: day.githubCount + giteaCount };
})
);
const days = mergedWeeks.flat().sort((first, second) => first.date.localeCompare(second.date));
const thresholds = levelThresholds(days.map((day) => day.count));
const weeks = mergedWeeks.map((week) => week.map((day) => ({ ...day, level: levelFor(day.count, thresholds) })));
const giteaTotal = days.reduce((sum, day) => sum + day.giteaCount, 0);
const bestDay = days.reduce<ContributionDay | null>((best, day) => (!best || day.count > best.count ? day : best), null);
return { total, weeks, currentStreak: currentStreak(days), bestDay };
return {
total: github.total + giteaTotal,
githubTotal: github.total,
giteaTotal,
giteaProfileUrl,
weeks,
currentStreak: currentStreak(days),
bestDay
};
}
+38
View File
@@ -0,0 +1,38 @@
const timeZone = 'America/New_York';
interface HeatmapEntry {
timestamp: number;
contributions: number;
}
export interface GiteaProfile {
baseUrl: string;
username: string;
profileUrl: string;
}
export function parseGiteaProfile(profileUrl: string | undefined): GiteaProfile | null {
if (!profileUrl) return null;
const url = new URL(profileUrl);
const username = url.pathname.split('/').filter(Boolean)[0];
return username ? { baseUrl: url.origin, username, profileUrl } : null;
}
function easternDate(timestampSeconds: number): string {
return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(
new Date(timestampSeconds * 1000)
);
}
export async function fetchGiteaDailyCounts({ baseUrl, username }: GiteaProfile): Promise<Map<string, number>> {
const response = await fetch(`${baseUrl}/api/v1/users/${username}/heatmap`, { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error(`Gitea heatmap returned ${response.status}`);
const entries: HeatmapEntry[] = await response.json();
const dailyCounts = new Map<string, number>();
for (const { timestamp, contributions } of entries) {
const date = easternDate(timestamp);
dailyCounts.set(date, (dailyCounts.get(date) ?? 0) + contributions);
}
return dailyCounts;
}
+134
View File
@@ -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);
}
+50
View File
@@ -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' });
}
+140
View File
@@ -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;
}
+126
View File
@@ -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
};
}
+57
View File
@@ -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;
}
+64
View File
@@ -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);
}
+159
View File
@@ -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 };
}
+30 -12
View File
@@ -9,7 +9,7 @@ const mp3Bitrate = '160k';
const youtube = new Ytdl();
const playlistVideoIds = playlist.videos.flatMap((video) => {
export const playlistVideoIds = playlist.videos.flatMap((video) => {
const videoId = parseVideoId(video);
return videoId ? [videoId] : [];
});
@@ -30,7 +30,8 @@ async function getTrack(videoId: string): Promise<Track> {
title: videoDetails.title,
artist: videoDetails.author.name,
durationSeconds: videoDetails.lengthSeconds,
cover: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`
cover: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
cached: false
};
trackCache.set(videoId, track);
return track;
@@ -45,7 +46,13 @@ export async function getPlaylistTracks(): Promise<Track[]> {
});
}
export function streamTrackAsMp3(videoId: string, startSeconds: number): ReadableStream<Uint8Array> {
export interface Transcode {
output: Readable;
finished: Promise<void>;
stop: () => void;
}
export function startTranscode(videoId: string, startSeconds: number): Transcode {
const downloadController = new AbortController();
const youtubeAudio = youtube.download(videoId, {
quality: Quality.HighestAudio,
@@ -62,17 +69,28 @@ export function streamTrackAsMp3(videoId: string, startSeconds: number): Readabl
if (ffmpeg.exitCode === null) ffmpeg.kill('SIGKILL');
};
youtubeAudio.on('error', (error) => {
console.error(`Music download failed for ${videoId}:`, error);
stop();
});
ffmpeg.on('error', (error) => {
console.error('ffmpeg failed to start:', error);
stop();
const finished = new Promise<void>((resolve, reject) => {
youtubeAudio.on('error', reject);
ffmpeg.on('error', reject);
ffmpeg.on('close', (exitCode) => (exitCode === 0 ? resolve() : reject(new Error(`ffmpeg exited with code ${exitCode}`))));
});
finished.catch(stop);
ffmpeg.stdin.on('error', stop);
ffmpeg.stdout.on('close', stop);
youtubeAudio.pipe(ffmpeg.stdin);
return Readable.toWeb(ffmpeg.stdout) as ReadableStream<Uint8Array>;
return { output: ffmpeg.stdout, finished, stop };
}
export function streamTrackAsMp3(videoId: string, startSeconds: number): ReadableStream<Uint8Array> {
const transcode = startTranscode(videoId, startSeconds);
let listenerLeft = false;
transcode.output.on('close', () => {
listenerLeft = true;
transcode.stop();
});
transcode.finished.catch((error) => {
if (!listenerLeft) console.error(`Music stream failed for ${videoId}:`, error.message);
});
return Readable.toWeb(transcode.output) as ReadableStream<Uint8Array>;
}
+101
View File
@@ -0,0 +1,101 @@
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { playlistVideoIds, startTranscode } from './music';
const cacheDirectory = path.resolve(process.env.MUSIC_CACHE_DIR ?? '.cache/music');
const songExtension = '.mp3';
const partialExtension = '.part';
const rangePattern = /^bytes=(\d*)-(\d*)$/;
const readySongs = new Set<string>();
let refreshStarted = false;
function songPath(videoId: string): string {
return path.join(cacheDirectory, `${videoId}${songExtension}`);
}
export function isSongCached(videoId: string): boolean {
return readySongs.has(videoId);
}
async function prepareCacheDirectory() {
await mkdir(cacheDirectory, { recursive: true });
const playlistIds = new Set(playlistVideoIds);
for (const fileName of await readdir(cacheDirectory)) {
const videoId = fileName.slice(0, -songExtension.length);
const isKeptSong = fileName.endsWith(songExtension) && playlistIds.has(videoId);
if (isKeptSong) readySongs.add(videoId);
else await rm(path.join(cacheDirectory, fileName), { force: true });
}
}
async function downloadSong(videoId: string) {
const partialPath = `${songPath(videoId)}${partialExtension}`;
const transcode = startTranscode(videoId, 0);
try {
await Promise.all([pipeline(transcode.output, createWriteStream(partialPath)), transcode.finished]);
const { size } = await stat(partialPath);
if (size === 0) throw new Error('ffmpeg produced an empty file');
await rename(partialPath, songPath(videoId));
readySongs.add(videoId);
} catch (error) {
transcode.stop();
await rm(partialPath, { force: true });
throw error;
}
}
export function refreshMusicCache() {
if (refreshStarted) return;
refreshStarted = true;
(async () => {
await prepareCacheDirectory();
for (const videoId of playlistVideoIds) {
if (readySongs.has(videoId)) continue;
try {
await downloadSong(videoId);
console.log(`Music cached: ${videoId}`);
} catch (error) {
console.error(`Music cache failed for ${videoId}:`, error instanceof Error ? error.message : error);
}
}
})().catch((error) => console.error('Music cache refresh failed:', error));
}
function parseRange(rangeHeader: string | null, fileSize: number): { start: number; end: number } | null {
const match = rangeHeader?.match(rangePattern);
if (!match) return { start: 0, end: fileSize - 1 };
const [, startText, endText] = match;
const start = startText ? Number(startText) : Math.max(0, fileSize - Number(endText));
const end = startText && endText ? Math.min(Number(endText), fileSize - 1) : fileSize - 1;
return start <= end && start < fileSize ? { start, end } : null;
}
export async function serveCachedSong(videoId: string, rangeHeader: string | null): Promise<Response> {
const filePath = songPath(videoId);
const { size } = await stat(filePath);
const range = parseRange(rangeHeader, size);
if (!range) {
return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${size}` } });
}
const fileStream = createReadStream(filePath, { start: range.start, end: range.end });
return new Response(Readable.toWeb(fileStream) as ReadableStream<Uint8Array>, {
status: rangeHeader ? 206 : 200,
headers: {
'Content-Type': 'audio/mpeg',
'Content-Length': `${range.end - range.start + 1}`,
'Content-Range': `bytes ${range.start}-${range.end}/${size}`,
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=3600'
}
});
}
+51
View File
@@ -0,0 +1,51 @@
const focusableSelector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
function focusableElements(container: HTMLElement): HTMLElement[] {
return [...container.querySelectorAll<HTMLElement>(focusableSelector)].filter(
(element) => element.getClientRects().length > 0
);
}
export function focusTrap(container: HTMLElement) {
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
(focusableElements(container)[0] ?? container).focus({ preventScroll: true });
function handleKeydown(event: KeyboardEvent) {
if (event.key !== 'Tab') return;
const elements = focusableElements(container);
const first = elements[0];
const last = elements.at(-1);
const active = document.activeElement;
if (!first || !last) {
event.preventDefault();
container.focus({ preventScroll: true });
} else if (!container.contains(active)) {
event.preventDefault();
first.focus();
} else if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
}
document.addEventListener('keydown', handleKeydown);
return {
destroy() {
document.removeEventListener('keydown', handleKeydown);
if (previouslyFocused?.isConnected) previouslyFocused.focus({ preventScroll: true });
}
};
}
+1 -1
View File
@@ -39,7 +39,7 @@
</script>
<div class="page">
<PageHeader command="curl {page.url.pathname}" title="Error {status}" />
<PageHeader command="curl {page.url.pathname}" title="Error {status}" meta={{ noIndex: true }} />
<pre class="ascii" class:server-error={status >= 500}>{asciiDigits.join('\n')}</pre>
+2
View File
@@ -38,6 +38,8 @@
{@html themeStyleTag}
<link rel="icon" href="/favicon.png" />
<meta name="author" content={profile.displayName} />
<link rel="apple-touch-icon" href="/favicon.png" />
<link rel="alternate" type="application/rss+xml" title="{profile.displayName}'s Blog" href="/blog/rss.xml" />
</svelte:head>
<svelte:window onkeydown={(event) => handleKeybind(event, openWindowIds)} />
+2 -5
View File
@@ -1,8 +1,5 @@
<script lang="ts">
import { profile } from '#lib/content';
import PageMeta from '#lib/components/ui/PageMeta.svelte';
</script>
<svelte:head>
<title>{profile.displayName} | Portfolio</title>
<meta name="description" content="{profile.title} - {profile.bio}" />
</svelte:head>
<PageMeta type="profile" />
+5 -3
View File
@@ -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
View File
@@ -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'
}
});
};
+2 -1
View File
@@ -1,8 +1,9 @@
import { json } from '@sveltejs/kit';
import { getPlaylistTracks } from '#lib/server/music';
import { isSongCached } from '#lib/server/musicCache';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
const tracks = await getPlaylistTracks();
const tracks = (await getPlaylistTracks()).map((track) => ({ ...track, cached: isSongCached(track.id) }));
return json(tracks, { headers: { 'Cache-Control': 'no-store' } });
};
+10 -1
View File
@@ -1,10 +1,19 @@
import { error } from '@sveltejs/kit';
import { isPlaylistVideo, streamTrackAsMp3 } from '#lib/server/music';
import { isSongCached, serveCachedSong } from '#lib/server/musicCache';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = ({ params, url }) => {
export const GET: RequestHandler = async ({ params, url, request }) => {
if (!isPlaylistVideo(params.id)) error(404, 'Song not found');
if (isSongCached(params.id)) {
try {
return await serveCachedSong(params.id, request.headers.get('range'));
} catch (cacheError) {
console.error(`Cached song unavailable for ${params.id}:`, cacheError);
}
}
const requestedStart = Number(url.searchParams.get('start') ?? 0);
const startSeconds = Number.isFinite(requestedStart) && requestedStart > 0 ? Math.floor(requestedStart) : 0;
+6 -1
View File
@@ -9,7 +9,12 @@
<div class="page">
<article class="article">
<PageHeader command="cat ~/blog/{data.post.slug}.md" title={data.post.title} subtitle={data.post.excerpt} />
<PageHeader
command="cat ~/blog/{data.post.slug}.md"
title={data.post.title}
subtitle={data.post.excerpt}
meta={{ type: 'article', publishedTime: data.post.date, tags: data.post.tags }}
/>
<div class="meta">
<a href="/blog">&lt; back to blog</a>