Compare commits
8
Commits
7ca0003800
...
2d84d68c46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d84d68c46 | ||
|
|
3122bc4bf9 | ||
|
|
fe60a4443d | ||
|
|
411fb03c22 | ||
|
|
2432113c7f | ||
|
|
33c9daa58b | ||
|
|
c819436481 | ||
|
|
0659b82e6f |
No files matched your search
@@ -15,6 +15,7 @@ UPDATE.md
|
|||||||
blogs
|
blogs
|
||||||
tools
|
tools
|
||||||
__pycache__
|
__pycache__
|
||||||
|
.cache
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
@@ -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=
|
||||||
@@ -24,6 +24,8 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
bun.lock
|
bun.lock
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
.cache/
|
||||||
|
data/
|
||||||
Update.md
|
Update.md
|
||||||
PROJECT.md
|
PROJECT.md
|
||||||
blogs/
|
blogs/
|
||||||
@@ -18,6 +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 /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." },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"title": "Computer Science Student",
|
"title": "Computer Science Student",
|
||||||
"email": "sirblob0@gmail.com",
|
"email": "sirblob0@gmail.com",
|
||||||
"donateUrl": "https://ko-fi.com/sirblob",
|
"donateUrl": "https://ko-fi.com/sirblob",
|
||||||
|
"giteaUrl": "https://git.sirblob.co/SirBlob",
|
||||||
"avatar": "/blob_nerd.png",
|
"avatar": "/blob_nerd.png",
|
||||||
"photo": "/portfolio-photo.jpg",
|
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"featured": true,
|
"featured": true,
|
||||||
"event": { "name": "Bitcamp 2026", "university": "University of Maryland", "location": "College Park, MD", "year": "2026" },
|
"event": { "name": "Bitcamp 2026", "university": "University of Maryland", "location": "College Park, MD", "year": "2026" },
|
||||||
"awards": [{ "track": "Best UI/UX Hack", "place": "Winner" }],
|
"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",
|
"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.",
|
"description": "Ever felt the indecisiveness of choosing what to eat? Don't fret! Decide your next bite with Food Decisive.",
|
||||||
"image": "/hacks/fooddecisive.png",
|
"image": "/hacks/fooddecisive.png",
|
||||||
"tags": ["AI", "Llama3.1", "Svelte", "Node.js"],
|
"tags": ["AI", "Llama3.1", "Svelte", "Node.js"],
|
||||||
"demoMayBeOffline": true,
|
|
||||||
"event": { "name": "VTHacks 12", "university": "Virginia Tech", "location": "Blacksburg, VA", "year": "2024" },
|
"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",
|
"name": "PatSafe",
|
||||||
@@ -79,15 +78,14 @@
|
|||||||
"image": "/hacks/drinkhappy.png",
|
"image": "/hacks/drinkhappy.png",
|
||||||
"tags": ["API", "Auth0", "Gemini", "JavaScript", "MongoDB", "Next.js", "React", "Tailwind", "Vercel"],
|
"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" },
|
"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",
|
"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.",
|
"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",
|
"image": "/hacks/roadcast.png",
|
||||||
"tags": ["React", "Node.js", "Python", "MongoDB", "Geospatial", "Mapbox"],
|
"tags": ["React", "Node.js", "Python", "MongoDB", "Geospatial", "Mapbox"],
|
||||||
"demoMayBeOffline": true,
|
|
||||||
"event": { "name": "VTHacks 13", "university": "Virginia Tech", "location": "Blacksburg, VA", "year": "2025" },
|
"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" }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -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,6 +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 { startLastfmSync } from '#lib/server/lastfm/sync';
|
||||||
|
import { about } from '#lib/content';
|
||||||
|
|
||||||
export const init: ServerInit = () => {
|
export const init: ServerInit = () => {
|
||||||
getDiscordClient();
|
getDiscordClient();
|
||||||
|
refreshMusicCache();
|
||||||
|
startLastfmSync(about.lastfmUsername);
|
||||||
};
|
};
|
||||||
@@ -2,10 +2,15 @@ export interface ContributionDay {
|
|||||||
date: string;
|
date: string;
|
||||||
level: number;
|
level: number;
|
||||||
count: number;
|
count: number;
|
||||||
|
githubCount: number;
|
||||||
|
giteaCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContributionCalendar {
|
export interface ContributionCalendar {
|
||||||
total: number;
|
total: number;
|
||||||
|
githubTotal: number;
|
||||||
|
giteaTotal: number;
|
||||||
|
giteaProfileUrl: string | null;
|
||||||
weeks: ContributionDay[][];
|
weeks: ContributionDay[][];
|
||||||
currentStreak: number;
|
currentStreak: number;
|
||||||
bestDay: ContributionDay | null;
|
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">
|
<script lang="ts">
|
||||||
import type { BtopData } from '#lib/btop/types';
|
import type { BtopData, ContributionDay } from '#lib/btop/types';
|
||||||
import BtopBox from './BtopBox.svelte';
|
import BtopBox from './BtopBox.svelte';
|
||||||
|
|
||||||
let { data }: { data: BtopData | null } = $props();
|
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);
|
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>
|
</script>
|
||||||
|
|
||||||
<BtopBox title="net · commits" number={3} color="blue">
|
<BtopBox title="net · commits" number={3} color="blue">
|
||||||
@@ -19,27 +30,38 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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)}
|
{#each calendar.weeks as week, weekIndex (weekIndex)}
|
||||||
<span class="week">
|
<span class="week">
|
||||||
{#each week as day (day.date)}
|
{#each week as day (day.date)}
|
||||||
<span
|
<span
|
||||||
class="day"
|
class="day"
|
||||||
style:opacity={day.level === 0 ? 1 : levelOpacity[day.level]}
|
class:empty={day.count === 0}
|
||||||
class:empty={day.level === 0}
|
style:background={dayBackground(day)}
|
||||||
title="{day.count} contributions on {day.date}"
|
style:opacity={levelOpacity[day.level]}
|
||||||
|
title={dayTitle(day)}
|
||||||
></span>
|
></span>
|
||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</a>
|
</div>
|
||||||
|
|
||||||
<div class="legend muted">
|
<div class="legend">
|
||||||
less
|
<a class="source" href={data.github.profileUrl} target="_blank" rel="noopener noreferrer">
|
||||||
{#each levelOpacity as opacity, level (level)}
|
<span class="swatch github"></span>GitHub <b>{calendar.githubTotal.toLocaleString('en-US')}</b>
|
||||||
<span class="day" class:empty={level === 0} style:opacity={level === 0 ? 1 : opacity}></span>
|
</a>
|
||||||
{/each}
|
{#if calendar.giteaProfileUrl}
|
||||||
more
|
<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="swatch" class:empty={level === 0} style:opacity></span>
|
||||||
|
{/each}
|
||||||
|
more
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="muted loading">connecting to github...</p>
|
<p class="muted loading">connecting to github...</p>
|
||||||
@@ -76,25 +98,57 @@
|
|||||||
display: block;
|
display: block;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
min-width: 4px;
|
min-width: 4px;
|
||||||
background: var(--green);
|
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day.empty {
|
.empty {
|
||||||
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend {
|
.legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
gap: 0.3rem 0.9rem;
|
||||||
gap: 3px;
|
margin-top: 0.5rem;
|
||||||
margin-top: 0.4rem;
|
font-size: 0.72rem;
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
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 {
|
.loading {
|
||||||
|
|||||||
@@ -1,22 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { profile } from '#lib/content';
|
|
||||||
import { randomWalk, usageTone } from '#lib/btop/simulate';
|
import { randomWalk, usageTone } from '#lib/btop/simulate';
|
||||||
import BtopBox from './BtopBox.svelte';
|
import BtopBox from './BtopBox.svelte';
|
||||||
|
|
||||||
const historyLength = 48;
|
const historyLength = 48;
|
||||||
const updateInterval = 1000;
|
const updateInterval = 1000;
|
||||||
const coreNames = (profile.skills.Languages ?? []).slice(0, 6);
|
|
||||||
|
|
||||||
let history = $state<number[]>(Array.from({ length: historyLength }, () => 20 + Math.random() * 30));
|
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));
|
const totalUsage = $derived(Math.round(history.at(-1) ?? 0));
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
history = [...history.slice(1), randomWalk(history.at(-1) ?? 40, 18, 5, 98)];
|
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);
|
}, updateInterval);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
});
|
});
|
||||||
@@ -33,16 +29,6 @@
|
|||||||
<span class="bar" style:height="{value}%" style:background="var(--{usageTone(value)})"></span>
|
<span class="bar" style:height="{value}%" style:background="var(--{usageTone(value)})"></span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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>
|
</BtopBox>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -69,7 +55,7 @@
|
|||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
height: 70px;
|
height: 70px;
|
||||||
margin: 0.4rem 0 0.6rem;
|
margin-top: 0.4rem;
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,40 +65,4 @@
|
|||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
transition: height 0.4s ease;
|
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>
|
</style>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { focusTrap } from '#lib/ui/focusTrap';
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import { musicPlayer } from '#lib/music/player.svelte';
|
import { musicPlayer } from '#lib/music/player.svelte';
|
||||||
import MusicNowPlaying from './MusicNowPlaying.svelte';
|
import MusicNowPlaying from './MusicNowPlaying.svelte';
|
||||||
@@ -19,7 +20,7 @@
|
|||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<button class="backdrop" aria-label="Close music player" onclick={onclose}></button>
|
<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">
|
<div class="popover-surface">
|
||||||
{#if track}
|
{#if track}
|
||||||
<MusicNowPlaying {track} />
|
<MusicNowPlaying {track} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { focusTrap } from '#lib/ui/focusTrap';
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import {
|
import {
|
||||||
@@ -41,7 +42,7 @@
|
|||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<button class="backdrop" aria-label="Close battery details" onclick={onclose}></button>
|
<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}>
|
<div class="popover-surface" class:charging={battery.charging} class:low={isLow}>
|
||||||
<header class="summary">
|
<header class="summary">
|
||||||
<span class="battery-icon">
|
<span class="battery-icon">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { focusTrap } from '#lib/ui/focusTrap';
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import { activityLabels, type DiscordPresence } from '#lib/discord/types';
|
import { activityLabels, type DiscordPresence } from '#lib/discord/types';
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<button class="backdrop" aria-label="Close Discord status" onclick={onclose}></button>
|
<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">
|
<div class="popover-surface">
|
||||||
<header class="profile">
|
<header class="profile">
|
||||||
<div class="avatar">
|
<div class="avatar">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { focusTrap } from '#lib/ui/focusTrap';
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
|
|
||||||
let { donateUrl, discordUrl, onclose }: { donateUrl: string; discordUrl: string; onclose: () => void } = $props();
|
let { donateUrl, discordUrl, onclose }: { donateUrl: string; discordUrl: string; onclose: () => void } = $props();
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<button class="backdrop" aria-label="Close donate card" onclick={onclose}></button>
|
<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">
|
<div class="popover-surface">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<Icon icon="simple-icons:kofi" width="22" />
|
<Icon icon="simple-icons:kofi" width="22" />
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
{#if track}
|
{#if track}
|
||||||
{#key musicPlayer.streamKey}
|
{#key musicPlayer.streamKey}
|
||||||
<audio
|
<audio
|
||||||
|
bind:this={musicPlayer.audioElement}
|
||||||
src={musicPlayer.streamUrl}
|
src={musicPlayer.streamUrl}
|
||||||
autoplay
|
autoplay
|
||||||
preload="auto"
|
preload="auto"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { focusTrap } from '#lib/ui/focusTrap';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
let { title, onclose, children }: { title: string; onclose: () => void; children: Snippet } = $props();
|
let { title, onclose, children }: { title: string; onclose: () => void; children: Snippet } = $props();
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<button class="backdrop" aria-label="Close {title}" onclick={onclose}></button>
|
<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-title">{title}</div>
|
||||||
<div class="popup-body">
|
<div class="popup-body">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
import { profile } from '#lib/content';
|
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 } =
|
let {
|
||||||
$props();
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<PageMeta {title} description={metaDescription} {...meta} />
|
||||||
{#if title}
|
|
||||||
<title>{title} | {profile.displayName}</title>
|
|
||||||
{/if}
|
|
||||||
{#if subtitle}
|
|
||||||
<meta name="description" content={subtitle} />
|
|
||||||
{/if}
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div class="command">
|
<div class="command">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -18,6 +18,7 @@ export interface Profile {
|
|||||||
title: string;
|
title: string;
|
||||||
email: string;
|
email: string;
|
||||||
donateUrl?: string;
|
donateUrl?: string;
|
||||||
|
giteaUrl?: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
photo?: string;
|
photo?: string;
|
||||||
bio: string;
|
bio: string;
|
||||||
@@ -138,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;
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ class MusicPlayer {
|
|||||||
history = $state<number[]>([]);
|
history = $state<number[]>([]);
|
||||||
repeat = $state(false);
|
repeat = $state(false);
|
||||||
|
|
||||||
|
audioElement: HTMLAudioElement | null = null;
|
||||||
|
|
||||||
private failedTrackCount = 0;
|
private failedTrackCount = 0;
|
||||||
private loading = false;
|
private loading = false;
|
||||||
|
|
||||||
@@ -108,7 +110,13 @@ class MusicPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
seek(seconds: number) {
|
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.elapsedSinceStart = 0;
|
||||||
this.streamKey += 1;
|
this.streamKey += 1;
|
||||||
this.paused = false;
|
this.paused = false;
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export interface Track {
|
|||||||
artist: string;
|
artist: string;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
cover: string;
|
cover: string;
|
||||||
|
cached: boolean;
|
||||||
}
|
}
|
||||||
+17
-3
@@ -1,6 +1,7 @@
|
|||||||
import { profile } from '#lib/content';
|
import { profile } from '#lib/content';
|
||||||
import type { BtopData, GithubSummary, RepoProcess } from '#lib/btop/types';
|
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 cacheDuration = 60 * 60 * 1000;
|
||||||
const githubHeaders = { Accept: 'application/vnd.github+json', 'User-Agent': 'portfolio-website' };
|
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 };
|
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[]> {
|
async function fetchRepos(username: string): Promise<RepoProcess[]> {
|
||||||
const repos = await fetchJson<GithubRepo[]>(`https://api.github.com/users/${username}/repos?per_page=100&sort=pushed`);
|
const repos = await fetchJson<GithubRepo[]>(`https://api.github.com/users/${username}/repos?per_page=100&sort=pushed`);
|
||||||
return repos
|
return repos
|
||||||
@@ -58,11 +70,13 @@ export async function getBtopData(): Promise<BtopData | null> {
|
|||||||
if (!username) return null;
|
if (!username) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [github, contributions, repos] = await Promise.all([
|
const [github, githubCalendar, giteaCounts, repos] = await Promise.all([
|
||||||
fetchSummary(username),
|
fetchSummary(username),
|
||||||
fetchContributionCalendar(username),
|
fetchGithubCalendar(username),
|
||||||
|
fetchGiteaCounts(),
|
||||||
fetchRepos(username)
|
fetchRepos(username)
|
||||||
]);
|
]);
|
||||||
|
const contributions = buildCalendar(githubCalendar, giteaCounts, profile.giteaUrl ?? null);
|
||||||
const data = { github, contributions, repos };
|
const data = { github, contributions, repos };
|
||||||
cached = { data, fetchedAt: Date.now() };
|
cached = { data, fetchedAt: Date.now() };
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -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 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 tooltipPattern = /for="contribution-day-component-(\d+-\d+)"[^>]*>(\d+|No) contributions? on/g;
|
||||||
const totalPattern = /([\d,]+)\s+contributions?\s+in the last year/;
|
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> {
|
function parseCounts(html: string): Map<string, number> {
|
||||||
const counts = new 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[][] {
|
function parseWeeks(html: string): ContributionDay[][] {
|
||||||
const counts = parseCounts(html);
|
const counts = parseCounts(html);
|
||||||
const weeks: ContributionDay[][] = [];
|
const weeks: ContributionDay[][] = [];
|
||||||
for (const [, date, weekday, week, level] of html.matchAll(dayCellPattern)) {
|
for (const [, date, weekday, week] of html.matchAll(dayCellPattern)) {
|
||||||
const weekIndex = Number(week);
|
const githubCount = counts.get(`${weekday}-${week}`) ?? 0;
|
||||||
weeks[weekIndex] ??= [];
|
weeks[Number(week)] ??= [];
|
||||||
weeks[weekIndex][Number(weekday)] = { date, level: Number(level), count: counts.get(`${weekday}-${week}`) ?? 0 };
|
weeks[Number(week)][Number(weekday)] = { date, level: 0, count: githubCount, githubCount, giteaCount: 0 };
|
||||||
}
|
}
|
||||||
return weeks.map((days) => days.filter(Boolean));
|
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 {
|
function currentStreak(days: ContributionDay[]): number {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const pastDays = days.filter((day) => day.date <= today);
|
const pastDays = days.filter((day) => day.date <= today);
|
||||||
@@ -33,17 +65,30 @@ function currentStreak(days: ContributionDay[]): number {
|
|||||||
return streak;
|
return streak;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchContributionCalendar(username: string): Promise<ContributionCalendar> {
|
export function buildCalendar(
|
||||||
const response = await fetch(`https://github.com/users/${username}/contributions`, {
|
github: GithubCalendar,
|
||||||
headers: { 'User-Agent': 'portfolio-website' }
|
giteaCounts: Map<string, number>,
|
||||||
});
|
giteaProfileUrl: string | null
|
||||||
if (!response.ok) throw new Error(`GitHub contributions returned ${response.status}`);
|
): ContributionCalendar {
|
||||||
|
const mergedWeeks = github.weeks.map((week) =>
|
||||||
const html = await response.text();
|
week.map((day) => {
|
||||||
const weeks = parseWeeks(html);
|
const giteaCount = giteaCounts.get(day.date) ?? 0;
|
||||||
const days = weeks.flat().sort((first, second) => first.date.localeCompare(second.date));
|
return { ...day, giteaCount, count: day.githubCount + giteaCount };
|
||||||
const total = Number(html.match(totalPattern)?.[1].replace(/,/g, '') ?? days.reduce((sum, day) => sum + day.count, 0));
|
})
|
||||||
|
);
|
||||||
|
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);
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
+30
-12
@@ -9,7 +9,7 @@ const mp3Bitrate = '160k';
|
|||||||
|
|
||||||
const youtube = new Ytdl();
|
const youtube = new Ytdl();
|
||||||
|
|
||||||
const playlistVideoIds = playlist.videos.flatMap((video) => {
|
export const playlistVideoIds = playlist.videos.flatMap((video) => {
|
||||||
const videoId = parseVideoId(video);
|
const videoId = parseVideoId(video);
|
||||||
return videoId ? [videoId] : [];
|
return videoId ? [videoId] : [];
|
||||||
});
|
});
|
||||||
@@ -30,7 +30,8 @@ async function getTrack(videoId: string): Promise<Track> {
|
|||||||
title: videoDetails.title,
|
title: videoDetails.title,
|
||||||
artist: videoDetails.author.name,
|
artist: videoDetails.author.name,
|
||||||
durationSeconds: videoDetails.lengthSeconds,
|
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);
|
trackCache.set(videoId, track);
|
||||||
return 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 downloadController = new AbortController();
|
||||||
const youtubeAudio = youtube.download(videoId, {
|
const youtubeAudio = youtube.download(videoId, {
|
||||||
quality: Quality.HighestAudio,
|
quality: Quality.HighestAudio,
|
||||||
@@ -62,17 +69,28 @@ export function streamTrackAsMp3(videoId: string, startSeconds: number): Readabl
|
|||||||
if (ffmpeg.exitCode === null) ffmpeg.kill('SIGKILL');
|
if (ffmpeg.exitCode === null) ffmpeg.kill('SIGKILL');
|
||||||
};
|
};
|
||||||
|
|
||||||
youtubeAudio.on('error', (error) => {
|
const finished = new Promise<void>((resolve, reject) => {
|
||||||
console.error(`Music download failed for ${videoId}:`, error);
|
youtubeAudio.on('error', reject);
|
||||||
stop();
|
ffmpeg.on('error', reject);
|
||||||
});
|
ffmpeg.on('close', (exitCode) => (exitCode === 0 ? resolve() : reject(new Error(`ffmpeg exited with code ${exitCode}`))));
|
||||||
ffmpeg.on('error', (error) => {
|
|
||||||
console.error('ffmpeg failed to start:', error);
|
|
||||||
stop();
|
|
||||||
});
|
});
|
||||||
|
finished.catch(stop);
|
||||||
|
|
||||||
ffmpeg.stdin.on('error', stop);
|
ffmpeg.stdin.on('error', stop);
|
||||||
ffmpeg.stdout.on('close', stop);
|
|
||||||
youtubeAudio.pipe(ffmpeg.stdin);
|
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>;
|
||||||
}
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<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>
|
<pre class="ascii" class:server-error={status >= 500}>{asciiDigits.join('\n')}</pre>
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,8 @@
|
|||||||
{@html themeStyleTag}
|
{@html themeStyleTag}
|
||||||
<link rel="icon" href="/favicon.png" />
|
<link rel="icon" href="/favicon.png" />
|
||||||
<meta name="author" content={profile.displayName} />
|
<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:head>
|
||||||
|
|
||||||
<svelte:window onkeydown={(event) => handleKeybind(event, openWindowIds)} />
|
<svelte:window onkeydown={(event) => handleKeybind(event, openWindowIds)} />
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { profile } from '#lib/content';
|
import PageMeta from '#lib/components/ui/PageMeta.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<PageMeta type="profile" />
|
||||||
<title>{profile.displayName} | Portfolio</title>
|
|
||||||
<meta name="description" content="{profile.title} - {profile.bio}" />
|
|
||||||
</svelte:head>
|
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { getPlaylistTracks } from '#lib/server/music';
|
import { getPlaylistTracks } from '#lib/server/music';
|
||||||
|
import { isSongCached } from '#lib/server/musicCache';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const GET: RequestHandler = async () => {
|
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' } });
|
return json(tracks, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
};
|
};
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { isPlaylistVideo, streamTrackAsMp3 } from '#lib/server/music';
|
import { isPlaylistVideo, streamTrackAsMp3 } from '#lib/server/music';
|
||||||
|
import { isSongCached, serveCachedSong } from '#lib/server/musicCache';
|
||||||
import type { RequestHandler } from './$types';
|
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 (!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 requestedStart = Number(url.searchParams.get('start') ?? 0);
|
||||||
const startSeconds = Number.isFinite(requestedStart) && requestedStart > 0 ? Math.floor(requestedStart) : 0;
|
const startSeconds = Number.isFinite(requestedStart) && requestedStart > 0 ? Math.floor(requestedStart) : 0;
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,12 @@
|
|||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<article class="article">
|
<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">
|
<div class="meta">
|
||||||
<a href="/blog">< back to blog</a>
|
<a href="/blog">< back to blog</a>
|
||||||
|
|||||||
Reference in new issue
Block a user