Merge branch 'feature/resources-stuff'

This commit is contained in:
Dan 2026-01-07 16:06:58 +00:00
commit f9f2d528f6
17 changed files with 2296 additions and 448 deletions

70
assets/js/code-copy.js Normal file
View file

@ -0,0 +1,70 @@
// Add copy buttons to all code blocks
document.addEventListener("DOMContentLoaded", function () {
// Find all <pre> elements that contain <code>
const codeBlocks = document.querySelectorAll("pre code");
codeBlocks.forEach((codeBlock) => {
const pre = codeBlock.parentElement;
// Create wrapper for positioning
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
// Wrap the pre element
pre.parentNode.insertBefore(wrapper, pre);
wrapper.appendChild(pre);
// Create copy button
const copyButton = document.createElement("button");
copyButton.className = "code-copy-btn";
copyButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
`;
copyButton.setAttribute("aria-label", "Copy code to clipboard");
// Add click handler
copyButton.addEventListener("click", async () => {
const code = codeBlock.textContent;
try {
await navigator.clipboard.writeText(code);
// Show success feedback
copyButton.classList.add("copied");
// Reset after 2 seconds
setTimeout(() => {
copyButton.classList.remove("copied");
}, 2000);
} catch (err) {
console.error("Failed to copy code:", err);
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = code;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
copyButton.classList.add("copied");
setTimeout(() => {
copyButton.classList.remove("copied");
}, 2000);
} catch (err2) {
console.error("Fallback copy failed:", err2);
}
document.body.removeChild(textArea);
}
});
wrapper.appendChild(copyButton);
});
});

260
assets/js/lastfm-stats.js Normal file
View file

@ -0,0 +1,260 @@
// Last.fm Stats Interactive Module
(function () {
"use strict";
const LASTFM_API_URL = "https://ws.audioscrobbler.com/2.0/";
const LASTFM_API_KEY = "3a4fef48fecc593d25e0f9a40df1fefe";
// Store current stats for export
let currentStats = {
artists: [],
totalTracks: 0,
period: "",
username: "",
};
// Calculate timestamps based on period
function getTimestamps(period) {
const now = Math.floor(Date.now() / 1000);
let from;
if (period === "7day") {
from = now - 7 * 24 * 60 * 60; // 7 days
} else if (period === "1month") {
from = now - 30 * 24 * 60 * 60; // 30 days
}
return { from, to: now };
}
// Fetch top artists for the specified period
async function fetchTopArtists(username, period) {
const url = `${LASTFM_API_URL}?method=user.gettopartists&user=${username}&api_key=${LASTFM_API_KEY}&format=json&period=${period}&limit=5`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch top artists: ${response.statusText}`);
}
const data = await response.json();
// Check for Last.fm API errors
if (data.error) {
throw new Error(data.message || "Last.fm API error");
}
return data.topartists?.artist || [];
}
// Fetch recent tracks to count total scrobbles in period
async function fetchTrackCount(username, period) {
const { from, to } = getTimestamps(period);
const url = `${LASTFM_API_URL}?method=user.getrecenttracks&user=${username}&api_key=${LASTFM_API_KEY}&format=json&from=${from}&to=${to}&limit=1`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch track count: ${response.statusText}`);
}
const data = await response.json();
// Check for Last.fm API errors
if (data.error) {
throw new Error(data.message || "Last.fm API error");
}
return data.recenttracks?.["@attr"]?.total || 0;
}
// Generate markdown format
function generateMarkdown() {
const periodText =
currentStats.period === "7day" ? "Past Week" : "Past Month";
let markdown = `## Last.fm Stats - ${periodText}\n\n`;
markdown += `**Total Tracks:** ${currentStats.totalTracks}\n\n`;
markdown += `**Top 5 Artists:**\n\n`;
currentStats.artists.forEach((artist) => {
markdown += `- [${artist.name}](${artist.url}) - ${artist.playcount} plays\n`;
});
return markdown;
}
// Generate plain text format
function generatePlainText() {
const periodText =
currentStats.period === "7day" ? "Past Week" : "Past Month";
let text = `Last.fm Stats - ${periodText}\n\n`;
text += `Total Tracks: ${currentStats.totalTracks}\n\n`;
text += `Top 5 Artists:\n\n`;
currentStats.artists.forEach((artist) => {
text += `- ${artist.name} - ${artist.playcount} plays\n`;
});
return text;
}
// Copy to clipboard
async function copyToClipboard(text, button) {
try {
await navigator.clipboard.writeText(text);
const originalText = button.textContent;
button.textContent = "Copied!";
button.classList.add("copied");
setTimeout(() => {
button.textContent = originalText;
button.classList.remove("copied");
}, 2000);
} catch (err) {
console.error("Failed to copy:", err);
alert("Failed to copy to clipboard");
}
}
// Display the stats
function displayStats(artists, totalTracks, period, username) {
const artistsList = document.getElementById("top-artists");
const totalTracksEl = document.getElementById("total-tracks");
// Store current stats for export
currentStats = { artists, totalTracks, period, username };
// Update total tracks
totalTracksEl.textContent = totalTracks;
// Clear and populate artists list
artistsList.innerHTML = "";
if (artists.length === 0) {
artistsList.innerHTML = "<li>No artists found for this period</li>";
return;
}
artists.forEach((artist) => {
const li = document.createElement("li");
li.innerHTML = `<a href="${artist.url}" target="_blank">${artist.name}</a> - ${artist.playcount} plays`;
artistsList.appendChild(li);
});
// Show export buttons
const exportButtons = document.getElementById("export-buttons");
if (exportButtons) {
exportButtons.style.display = "flex";
}
}
// Show/hide UI elements
function setLoadingState(isLoading) {
const loading = document.getElementById("stats-loading");
const content = document.getElementById("stats-content");
const error = document.getElementById("stats-error");
const results = document.getElementById("stats-results");
results.style.display = "block";
if (isLoading) {
loading.style.display = "block";
content.style.display = "none";
error.style.display = "none";
} else {
loading.style.display = "none";
}
}
function showError(message) {
const error = document.getElementById("stats-error");
const errorMessage = document.getElementById("error-message");
const content = document.getElementById("stats-content");
error.style.display = "block";
content.style.display = "none";
errorMessage.textContent = message;
}
function showContent() {
const error = document.getElementById("stats-error");
const content = document.getElementById("stats-content");
error.style.display = "none";
content.style.display = "block";
}
// Main fetch function
async function fetchStats() {
const username = document.getElementById("lastfm-username").value.trim();
const period = document.getElementById("time-period").value;
if (!username) {
showError("Please enter a Last.fm username");
return;
}
setLoadingState(true);
try {
// Fetch both stats in parallel
const [artists, totalTracks] = await Promise.all([
fetchTopArtists(username, period),
fetchTrackCount(username, period),
]);
displayStats(artists, totalTracks, period, username);
showContent();
} catch (error) {
console.error("Error fetching Last.fm stats:", error);
showError(
error.message ||
"Failed to fetch stats. Please check the username and try again.",
);
} finally {
setLoadingState(false);
}
}
// Initialize when DOM is ready
function init() {
const fetchButton = document.getElementById("fetch-stats");
const usernameInput = document.getElementById("lastfm-username");
const copyMarkdownBtn = document.getElementById("copy-markdown");
const copyPlainTextBtn = document.getElementById("copy-plaintext");
if (!fetchButton || !usernameInput) {
return; // Not on the stats page
}
// Fetch stats on button click
fetchButton.addEventListener("click", fetchStats);
// Also fetch on Enter key in username input
usernameInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
fetchStats();
}
});
// Copy buttons
if (copyMarkdownBtn) {
copyMarkdownBtn.addEventListener("click", () => {
const markdown = generateMarkdown();
copyToClipboard(markdown, copyMarkdownBtn);
});
}
if (copyPlainTextBtn) {
copyPlainTextBtn.addEventListener("click", () => {
const plainText = generatePlainText();
copyToClipboard(plainText, copyPlainTextBtn);
});
}
}
// Run init when DOM is loaded
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

33
assets/js/pgp-copy.js Normal file
View file

@ -0,0 +1,33 @@
// PGP Key copy functionality
document.addEventListener('DOMContentLoaded', function() {
const copyButtons = document.querySelectorAll('.pgp-copy-trigger');
copyButtons.forEach(button => {
button.addEventListener('click', async function() {
const feedback = button.closest('.contact-pgp').querySelector('.pgp-copy-feedback');
try {
const response = await fetch('/publickey.asc');
const pgpKey = await response.text();
await navigator.clipboard.writeText(pgpKey);
feedback.textContent = 'PGP key copied to clipboard!';
feedback.classList.add('show', 'success');
setTimeout(() => {
feedback.classList.remove('show');
}, 3000);
} catch (err) {
console.error('Failed to copy PGP key:', err);
feedback.textContent = 'Failed to copy key';
feedback.classList.add('show', 'error');
setTimeout(() => {
feedback.classList.remove('show');
}, 3000);
}
});
});
});

View file

@ -559,7 +559,7 @@
} }
// Contact section styling // Contact section styling
.blog-contact-section { .contact-section {
margin-top: 40px; margin-top: 40px;
padding: 20px; padding: 20px;
background: rgba(0, 255, 0, 0.05); background: rgba(0, 255, 0, 0.05);

File diff suppressed because it is too large Load diff

View file

@ -26,7 +26,7 @@
@import "pages/media"; @import "pages/media";
@import "pages/resources"; @import "pages/resources";
@import url("https://fonts.bunny.net/css2?family=Caveat:wght@400..700&family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&family=Neonderthaw&display=swap"); @import url(https://fonts.bunny.net/css?family=abel:400|barlow-condensed:400,500|caveat:400|lato:300,300i,400,400i|neonderthaw:400);
@font-face { @font-face {
font-family: "DSEG7-Classic"; font-family: "DSEG7-Classic";

View file

@ -3,6 +3,6 @@ title: "Resources"
draft: false draft: false
--- ---
# Resources Welcome to my test chamber, here you'll find various experiements, tests and resources. Take a look around.
Welcome to my whiteboard of resources. Here you'll find various tools, scripts, and experiments I've built and wanted to share. The cake isn't a lie.

View file

@ -1,7 +1,6 @@
--- ---
title: "Last.fm Weekly Stats Script" title: "Last.fm Weekly Stats"
date: 2026-01-04 date: 2026-01-04
tags: ["javascript", "api", "last.fm"]
description: "Fetch and display your weekly listening stats from Last.fm" description: "Fetch and display your weekly listening stats from Last.fm"
icon: "lastfm-stats" icon: "lastfm-stats"
demo_url: "" demo_url: ""
@ -9,26 +8,115 @@ source_url: ""
draft: false draft: false
--- ---
A handy script for pulling your weekly listening statistics from Last.fm's API. Perfect for tracking your music habits and creating weekly listening reports. Get your weekly listening statistics from Last.fm's API. Enter your username below to see your top artists and track counts for different time periods.
## Features I made this so I could easily add in listening stats to my [weekly posts](/tags/weekly-update/), if you find it useful please let me know.
- Fetches weekly top tracks from Last.fm {{< lastfm-stats-form >}}
- Displays track count and listening time
- Formats data in a clean, readable format
- Easy to integrate into blogs or dashboards
## Setup ## Download the Shell Script
1. Get your Last.fm API key from [Last.fm API](https://www.last.fm/api) Want to run this locally or integrate it into your own workflows? Here is a bash script I was using to generate this before I decided to make a web version.
2. Configure your username in the script
3. Run the script to fetch your weekly stats
## Output ```
#!/bin/bash
The script returns your top tracks for the week, including: # Last.fm Weekly Stats Script
- Track name and artist # Fetches your Last.fm listening statistics for the past week
- Play count #
- Listening duration # Requirements:
# - curl (for API requests)
# - jq (for JSON parsing)
#
# Usage: ./lastfm-week.sh
#
# Setup:
# Create a .env file with:
# LASTFM_API_KEY=your_api_key_here
# LASTFM_USERNAME=your_username_here
#
# Output: Markdown-formatted stats with top artists and track counts
#
# Download from: https://ritual.sh/resources/lastfm-stats/
Great for weekly blog posts or personal music tracking! # Load environment variables from .env file
if [ -f .env ]; then
export $(cat .env | grep -v '^#' | xargs)
else
echo "Error: .env file not found"
exit 1
fi
# Check required variables
if [ -z "$LASTFM_API_KEY" ] || [ -z "$LASTFM_USERNAME" ]; then
echo "Error: LASTFM_API_KEY and LASTFM_USERNAME must be set in .env file"
exit 1
fi
API_BASE="http://ws.audioscrobbler.com/2.0/"
# Get current timestamp
NOW=$(date +%s)
# Get timestamp from 7 days ago
WEEK_AGO=$((NOW - 604800))
# Fetch top artists for the week
TOP_ARTISTS=$(curl -s "${API_BASE}?method=user.gettopartists&user=${LASTFM_USERNAME}&api_key=${LASTFM_API_KEY}&format=json&period=7day&limit=5")
# Fetch recent tracks to count this week's scrobbles
RECENT_TRACKS=$(curl -s "${API_BASE}?method=user.getrecenttracks&user=${LASTFM_USERNAME}&api_key=${LASTFM_API_KEY}&format=json&from=${WEEK_AGO}&to=${NOW}&limit=1")
# Get total track count
TOTAL_TRACKS=$(echo "$RECENT_TRACKS" | jq -r '.recenttracks["@attr"].total')
# Output in markdown format
echo "## Last.fm Weekly Stats"
echo ""
echo "**Total Tracks:** ${TOTAL_TRACKS}"
echo ""
echo "**Top 5 Artists:**"
echo ""
# Parse and display top 5 artists as markdown links
echo "$TOP_ARTISTS" | jq -r '.topartists.artist[] | "- [\(.name)](\(.url)) - \(.playcount) plays"'
```
### Shell Script Usage
The script fetches your Last.fm stats and outputs them in markdown format.
**Requirements:**
- `curl` for API requests
- `jq` for JSON parsing
**Setup:**
1. Create a `.env` file with your credentials:
```bash
LASTFM_API_KEY=your_api_key_here
LASTFM_USERNAME=your_username_here
```
2. Make the script executable:
```bash
chmod +x lastfm-week.sh
```
3. Run it:
```bash
./lastfm-week.sh
```
**Output:**
The script prints markdown-formatted stats including:
- Total tracks scrobbled this week
- Top 5 artists with play counts
- Direct links to artist pages
Enjoy!

View file

@ -1,12 +1,11 @@
--- ---
title: "HTML/CSS Lavalamp" title: "HTML/CSS Lavalamp"
date: 2026-01-04 date: 2026-01-04
tags: ["css", "html", "animation"]
description: "A pure CSS lavalamp animation with floating blobs" description: "A pure CSS lavalamp animation with floating blobs"
icon: "lavalamp" icon: "lavalamp"
demo_url: "" demo_url: ""
source_url: "" source_url: ""
draft: false draft: true
--- ---
A mesmerizing lavalamp effect created entirely with HTML and CSS. Features smooth floating animations and gradient blobs that rise and fall like a classic lava lamp. A mesmerizing lavalamp effect created entirely with HTML and CSS. Features smooth floating animations and gradient blobs that rise and fall like a classic lava lamp.

View file

@ -1,7 +1,22 @@
#!/bin/bash #!/bin/bash
# Last.fm Weekly Stats Script # Last.fm Weekly Stats Script
# Usage: ./lastfm-weekly.sh # Fetches your Last.fm listening statistics for the past week
#
# Requirements:
# - curl (for API requests)
# - jq (for JSON parsing)
#
# Usage: ./lastfm-week.sh
#
# Setup:
# Create a .env file with:
# LASTFM_API_KEY=your_api_key_here
# LASTFM_USERNAME=your_username_here
#
# Output: Markdown-formatted stats with top artists and track counts
#
# Download from: https://ritual.sh/resources/lastfm-stats/
# Load environment variables from .env file # Load environment variables from .env file
if [ -f .env ]; then if [ -f .env ]; then

View file

@ -23,36 +23,7 @@
<div class="blog-summary">{{ .Content }}</div> <div class="blog-summary">{{ .Content }}</div>
</article> </article>
<section class="blog-contact-section"> {{ partial "contact-section.html" . }}
<h2 class="contact-title">Contact</h2>
<div class="contact-content">
<p>
If you found this interesting, have any comments, questions,
corrections, or just fancy saying "hello" please feel free to get
in touch.
</p>
<div class="contact-email">
<span class="contact-label">Email:</span>
<a href="mailto:dan@ritual.sh">dan@ritual.sh</a>
</div>
<div class="contact-pgp">
<span class="contact-label">PGP Public Key:</span>
<div class="pgp-actions">
<a
href="/publickey.asc"
download
class="pgp-button download-key"
>
<span class="button-icon"></span> Download
</a>
<button id="copy-pgp-key" class="pgp-button copy-key">
<span class="button-icon"></span> Copy to Clipboard
</button>
</div>
<div id="copy-feedback" class="copy-feedback"></div>
</div>
</div>
</section>
<nav class="blog-post-navigation"> <nav class="blog-post-navigation">
<div class="post-nav-links"> <div class="post-nav-links">
@ -80,36 +51,4 @@
<script src="/js/footnote-scroll.js"></script> <script src="/js/footnote-scroll.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
const copyButton = document.getElementById("copy-pgp-key");
const feedback = document.getElementById("copy-feedback");
if (copyButton) {
copyButton.addEventListener("click", async function () {
try {
const response = await fetch("/publickey.asc");
const pgpKey = await response.text();
await navigator.clipboard.writeText(pgpKey);
feedback.textContent = "PGP key copied to clipboard!";
feedback.classList.add("show", "success");
setTimeout(() => {
feedback.classList.remove("show");
}, 3000);
} catch (err) {
feedback.textContent = "Failed to copy key";
feedback.classList.add("show", "error");
setTimeout(() => {
feedback.classList.remove("show");
}, 3000);
}
});
}
});
</script>
{{ end }} {{ end }}

View file

@ -0,0 +1,30 @@
<section class="contact-section">
<h2 class="contact-title">Contact</h2>
<div class="contact-content">
<p>
If you found this interesting, have any comments, questions,
corrections, or just fancy saying "hello" please feel free to get
in touch.
</p>
<div class="contact-email">
<span class="contact-label">Email:</span>
<a href="mailto:dan@ritual.sh">dan@ritual.sh</a>
</div>
<div class="contact-pgp">
<span class="contact-label">PGP Public Key:</span>
<div class="pgp-actions">
<a
href="/publickey.asc"
download
class="pgp-button download-key"
>
<span class="button-icon"></span> Download
</a>
<button class="pgp-button copy-key pgp-copy-trigger">
<span class="button-icon"></span> Copy to Clipboard
</button>
</div>
<div class="copy-feedback pgp-copy-feedback"></div>
</div>
</div>
</section>

View file

@ -1,15 +1,37 @@
{{ define "main" }} {{ define "main" }}
<div class="resources-page"> <div class="resources-page portal-theme">
<div class="resources-container"> <div class="portal-bg-elements">
<div class="whiteboard"> <div class="aperture-logo"></div>
<div class="whiteboard-header"> <div class="test-chamber-grid"></div>
<div class="whiteboard-description">{{ .Content }}</div> </div>
</div>
<div class="whiteboard-pins"> <div class="resources-container">
{{ range .Pages }} {{ .Render "summary" }} {{ end }} <div class="portal-header">
<div class="portal-icon blue-portal hidden-lg-down"></div>
<h1 class="portal-title">Resources</h1>
<div class="portal-icon orange-portal hidden-md-down"></div>
</div>
<div class="portal-sign">
<div class="portal-sign-decor">
<div class="portal-sign-text">{{ .Content }}</div>
<div class="portal-sign-number">06</div>
<div class="portal-sign-sub">06/19</div>
</div>
<div class="portal-sign-lines"></div>
<div class="portal-sign-content">
<div class="portal-sign-header">
<div class="portal-description"></div>
</div>
<div class="portal-sign-items">
{{ range .Pages }} {{ .Render "summary" }} {{ end }}
</div>
</div> </div>
</div> </div>
<div class="companion-cube"></div>
</div> </div>
</div> </div>
{{ end }} {{ end }}

View file

@ -1,8 +1,16 @@
{{ define "main" }} {{ define "main" }}
<div class="resource-single"> <div class="resource-single">
<div class="test-chamber-grid">
</div>
<article class="resource-content"> <article class="resource-content">
<!-- Aperture Science hazard stripes in corners -->
<!-- Animated energy particles -->
<div class="energy-particles"></div>
<header class="resource-header"> <header class="resource-header">
<div class="resource-icon-large {{ .Params.icon }}"></div>
<h1>{{ .Title }}</h1> <h1>{{ .Title }}</h1>
{{ if .Params.description }} {{ if .Params.description }}
<p class="lead">{{ .Params.description }}</p> <p class="lead">{{ .Params.description }}</p>
@ -28,6 +36,8 @@
{{ .Content }} {{ .Content }}
</div> </div>
{{ partial "contact-section.html" . }}
<nav class="resource-navigation"> <nav class="resource-navigation">
{{ with .PrevInSection }} {{ with .PrevInSection }}
<a href="{{ .Permalink }}" class="nav-link prev">← {{ .Title }}</a> <a href="{{ .Permalink }}" class="nav-link prev">← {{ .Title }}</a>

View file

@ -1,25 +1,8 @@
<div class="resource-pin" data-icon="{{ .Params.icon }}"> <div class="resource-pin" data-icon="{{ .Params.icon }}">
<div class="pin-tack"></div> <a href="{{ .Permalink }}">
<div class="resource-card"> <div class="resource-card">
<div class="resource-icon {{ .Params.icon }}"></div> <div class="resource-icon {{ .Params.icon }}"></div>
<div class="resource-info"> <div class="resource-info">{{ .Title }}</div>
<h2><a href="{{ .Permalink }}">{{ .Title }}</a></h2>
<p class="resource-description">{{ .Params.description }}</p>
{{ if .Params.tags }}
<div class="resource-tags">
{{ range .Params.tags }}
<span class="tag">{{ . }}</span>
{{ end }}
</div>
{{ end }}
<div class="resource-links">
{{ if .Params.demo_url }}
<a href="{{ .Params.demo_url }}" class="resource-link demo" target="_blank">Demo</a>
{{ end }}
{{ if .Params.source_url }}
<a href="{{ .Params.source_url }}" class="resource-link source" target="_blank">Source</a>
{{ end }}
</div>
</div> </div>
</div> </a>
</div> </div>

View file

@ -0,0 +1,51 @@
<div id="lastfm-stats-app">
<div class="stats-form">
<div class="form-group">
<label for="lastfm-username">Last.fm Username</label>
<input
type="text"
id="lastfm-username"
placeholder="Enter your username"
/>
</div>
<div class="form-group">
<label for="time-period">Time Period</label>
<select id="time-period">
<option value="7day">Past Week (7 days)</option>
<option value="1month">Past Month (30 days)</option>
</select>
</div>
<button id="fetch-stats" class="btn-primary">Get Stats</button>
</div>
<div id="stats-results" class="stats-results" style="display: none">
<div id="stats-loading" class="loading" style="display: none">
<p>Loading your stats...</p>
</div>
<div id="stats-content" style="display: none">
<h2>Your Last.fm Stats</h2>
<div class="stat-box">
<h3>Total Tracks</h3>
<p id="total-tracks" class="stat-number">-</p>
</div>
<div class="stat-box">
<h3>Top 5 Artists</h3>
<ul id="top-artists" class="artist-list"></ul>
</div>
</div>
<div id="export-buttons" class="export-buttons" style="display: none">
<button id="copy-markdown" class="btn-export">Copy as Markdown</button>
<button id="copy-plaintext" class="btn-export">Copy as Plain Text</button>
</div>
<div id="stats-error" class="error" style="display: none">
<p id="error-message"></p>
</div>
</div>
</div>

60
static/lastfm-week.sh Executable file
View file

@ -0,0 +1,60 @@
#!/bin/bash
# Last.fm Weekly Stats Script
# Fetches your Last.fm listening statistics for the past week
#
# Requirements:
# - curl (for API requests)
# - jq (for JSON parsing)
#
# Usage: ./lastfm-week.sh
#
# Setup:
# Create a .env file with:
# LASTFM_API_KEY=your_api_key_here
# LASTFM_USERNAME=your_username_here
#
# Output: Markdown-formatted stats with top artists and track counts
#
# Download from: https://ritual.sh/resources/lastfm-stats/
# Load environment variables from .env file
if [ -f .env ]; then
export $(cat .env | grep -v '^#' | xargs)
else
echo "Error: .env file not found"
exit 1
fi
# Check required variables
if [ -z "$LASTFM_API_KEY" ] || [ -z "$LASTFM_USERNAME" ]; then
echo "Error: LASTFM_API_KEY and LASTFM_USERNAME must be set in .env file"
exit 1
fi
API_BASE="http://ws.audioscrobbler.com/2.0/"
# Get current timestamp
NOW=$(date +%s)
# Get timestamp from 7 days ago
WEEK_AGO=$((NOW - 604800))
# Fetch top artists for the week
TOP_ARTISTS=$(curl -s "${API_BASE}?method=user.gettopartists&user=${LASTFM_USERNAME}&api_key=${LASTFM_API_KEY}&format=json&period=7day&limit=5")
# Fetch recent tracks to count this week's scrobbles
RECENT_TRACKS=$(curl -s "${API_BASE}?method=user.getrecenttracks&user=${LASTFM_USERNAME}&api_key=${LASTFM_API_KEY}&format=json&from=${WEEK_AGO}&to=${NOW}&limit=1")
# Get total track count
TOTAL_TRACKS=$(echo "$RECENT_TRACKS" | jq -r '.recenttracks["@attr"].total')
# Output in markdown format
echo "## Last.fm Weekly Stats"
echo ""
echo "**Total Tracks:** ${TOTAL_TRACKS}"
echo ""
echo "**Top 5 Artists:**"
echo ""
# Parse and display top 5 artists as markdown links
echo "$TOP_ARTISTS" | jq -r '.topartists.artist[] | "- [\(.name)](\(.url)) - \(.playcount) plays"'