Projects

Things I've built

Mostly Python, Docker and stubbornness. Usually started because I needed one tiny thing, then looked up three hours later and discovered I had built an ecosystem.

๐Ÿ’–

Myspace Recreation

A personalized little disaster from the 2006 internet

A standalone profile page built to feel like somebody spent an entire evening editing layout code they only half understood. It has the classic two-column profile, a real Top 8 with Tom in the first spot, awful tiled backgrounds, Comic Sans, neon stickers, scrolling status text, a profile song, blog blurbs and fake comments from the internet, my server and Harley.

The nostalgia is intentionally messy, but the implementation underneath still behaves like the rest of the site. The photos stay square instead of stretching, the page adapts to phones, reduced-motion settings stop the flashing pieces, the music player uses working native controls, and every fake Myspace action leads somewhere real on lilharper.dev.

HTMLCSSResponsive Accessible motionYouTube embedTop 8

Hand-written source

503 physical lines across its HTML and dedicated stylesheet

Top 8

8 square, responsive tiles with Tom permanently in first place

Real destinations

29 working links behind the fake profile navigation and actions

Counted from myspace.html and css/myspace.css on July 30, 2026.

The Myspace recreation with neon tiled wallpaper, Harper's profile and the profile-song player
A tighter crop of the header, profile and player. Restrained taste was not part of the brief.

Real code from the projects

Small, readable pieces from the code that powers Hazel and the game-server tools. Each one includes the result beside it, plus a Discord-ready copy button that adds the fenced formatting for you.

Hazel

16,385 Python lines across 36 production files and 29 module files

Hazel Care

7,519 source lines across 25 Python files and one web interface

lilharper.dev

48,584 front-end source lines across 173 files, serving 45 public pages

Counted from the source folders on July 30, 2026. These are physical lines, including comments and intentional whitespace, with backups, dependencies, generated builds and retired files excluded.

The neon tiled background and profile layout produced by the Myspace stylesheet
css/myspace.css

The intentionally terrible wallpaper

Layered CSS gradients create the tiled neon background without a repeating image file. Comic Sans and a crosshair cursor finish the period-correct damage.

body {
  color: var(--my-ink);
  background:
    radial-gradient(
      circle at 15px 15px,
      #ff00a8 0 5px,
      transparent 6px
    ) 0 0 / 30px 30px,
    radial-gradient(
      circle at 0 0,
      #00f5ff 0 4px,
      transparent 5px
    ) 0 0 / 30px 30px,
    repeating-conic-gradient(
      from 45deg,
      #1b0033 0 10deg,
      #50004e 10deg 20deg,
      #030013 20deg 30deg
    );
  background-attachment: fixed;
  font-family: "Comic Sans MS", Arial, sans-serif;
  cursor: crosshair;
}
Myspace Tom in the first square Top 8 tile
css/myspace.css

The Top 8 image repair

The explicit auto height is the important line. It stops each image's original HTML dimensions from stretching the tile after the grid has chosen its width.

.top-eight {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 12px 9px;
  text-align: center;
}

.friend img {
  width: 100%;
  height: auto;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  border: 2px solid #5f3a8c;
  padding: 2px;
  background: #fff;
}
Hazel's categorized help command displayed in Discord
modules/help.py

Categorized Discord help embeds

Hazel builds this Discord embed from a deliberately small category map. Commands that are not listed still fall into โ€œOther,โ€ so nothing silently disappears.

CATEGORIES = [
    ("๐ŸŽฎ Hytale", ["whitelist", "hytalestatus", "hytalemods"]),
    ("โš™๏ธ Utility", ["ping", "version", "status", "help"]),
]

def build_overview_embed(bot) -> discord.Embed:
    embed = discord.Embed(
        title=f"๐Ÿบ {BOT_NAME} Commands",
        color=0x8E7DFF,
    )

    for category_name, command_names in CATEGORIES:
        lines = [
            _command_line(bot.get_command(name))
            for name in command_names
            if bot.get_command(name)
        ]
        if lines:
            embed.add_field(
                name=category_name,
                value="\n\n".join(lines),
                inline=False,
            )

    return embed
Hytale server status rendered as a Discord embed
modules/hytale.py

One live status source, two front ends

Hazel reads the same endpoint as this website, then turns the response into the Discord status card shown above.

async def fetch_status() -> dict | None:
    try:
        async with aiohttp.ClientSession() as session:
            async with session.get(
                HYTALE_STATUS_URL,
                timeout=8,
            ) as response:
                if response.status != 200:
                    return None
                return await response.json()
    except Exception:
        return None

def build_status_embed(data: dict | None) -> discord.Embed:
    online = bool(data and data.get("online"))
    return discord.Embed(
        title="๐ŸงŠ Hytale Server",
        description=(
            "๐ŸŸข Online ยท play.lilharper.dev:5520"
            if online
            else "๐Ÿ”ด Offline ยท play.lilharper.dev:5520"
        ),
        color=0x57F287 if online else 0xE74C3C,
    )
๐Ÿง 

Hazel

Personal Assistant & Community Management Bot · running since late 2024

Hazel is a modular Discord bot, personal assistant and increasingly ridiculous command centre. I've been building and expanding her for over a year. She started as a simple reminder bot and grew into a whole ecosystem: routine tracking, moderation, community management, music relaying, server controls, site deployment, and the tiny quality-of-life buttons I keep adding because apparently opening a terminal counts as unacceptable friction now. Hazel is what powers my private community and a surprising amount of my day.

Technical deep dive: architecture and music relay

The modular part is the whole trick. Every feature lives in its own cog, so a broken experiment takes down one command instead of the entire bot, and I can add something new at midnight without touching anything that already works. That's the only reason a project this size has survived being maintained by exactly one person who does this for fun. I don't have a team to catch my mistakes, so the architecture has to do it for me.

๐ŸŽต The music relay, and why it isn't a music bot

Hazel can now play my music into a Discord voice channel, and the way she does it is the interesting part. She doesn't download anything, search anything, or pull a single byte from YouTube. Every music bot that got shut down (Rythm, Groovy, and the rest) died because it was extracting streams from a service that didn't want to be extracted from. So this does the opposite. It's a relay, not a player.

The signal path is the same one a physical cable would take. My music plays on my own desktop as normal. VoiceMeeter, a virtual audio mixer, takes that audio and splits it two ways: out to my headphones so I still hear it, and out to a virtual recording device. FFmpeg opens that virtual device exactly as it would open a microphone, and hands the live audio to Hazel's Discord voice connection. Whatever is coming out of my speakers is what the channel hears, a second later. Nothing is stored and nothing is fetched. If I pause the music the stream goes quiet, because there was never a file involved at any point.

The other half is the remote control. The YouTube Music desktop app runs a small local server for exactly this purpose, so the Command Centre pairs with it once and can then show what's playing and drive play, pause, skip and volume. It's a remote, not a second player: press skip in the app, on my keyboard, or in Hazel, and all three are looking at the same thing.

Two details I wouldn't have guessed before building it. Discord voice channels default to settings tuned for speech, and music through a speech-optimised encoder sounds like a phone call, so the encoder gets explicitly told it's carrying music and handed the channel's full bitrate. The second one is nastier. If anything downstream stalls for even a moment, a live capture will silently discard audio instead of waiting, and you hear that as random stuttering, not as an error. So FFmpeg gets a much larger input queue than its default of eight packets.

Pythondiscord.pyFastAPI PostgreSQLDockerFlask FFmpegVoiceMeeterOpusTkinter

๐Ÿง  How Hazel supports my ADHD day-to-day โ†’ · ๐Ÿงช See the Command Centre's real code โ†’ · โœ๏ธ Why I build things like this โ†’

Hazel V3 Command Center desktop app Hazel help command Hazel status embed
๐ŸŒ™

Hazel Care

Privacy-first executive-function assistant

Despite the shared name, this is a completely separate project from the Discord bot above. Hazel Care is a standalone desktop app: a native window over a local FastAPI server, with its own SQLite database and no AI in it anywhere. It's a reminder and checklist engine built to take as much executive function out of the loop as possible. Water, medication, meals, habits, routine and tasks all live in one panel, with a "Right now" card at the top so there's one decision on screen instead of a wall of them.

The alert engine is the actual product. Nothing is pre-scheduled: one job runs every minute and re-derives what's due straight from the database, so reminders survive the PC sleeping, the app restarting, or me editing something halfway through the day. If I ignore an alert it repeats and climbs in priority. It doesn't quietly disappear. It goes to the desktop, and to my phone over ntfy once I have clearly stopped looking at the screen. All of it runs offline on my own machine, because a self-care tool that stops working when a key expires is worse than no tool at all. You only find out on the day you needed it.

PythonFastAPISQLiteAPSchedulerpywebviewntfyNo AI
๐Ÿ“บ

Hilray TV

Home-hosted Jellyfin library with a locked-down public doorway

The video files stay on my home PC because that is where the storage already is. Jellyfin turns them into a real library, but I did not want to expose port 8096 or my home address to the internet just to watch something in a browser. So the PC opens an outbound reverse SSH tunnel to my VPS instead. Caddy talks to that private Unix socket and gives the service HTTPS at watch.lilharper.dev.

The tunnel uses its own SSH key, separate from the key Hazel uses to deploy this site. If Jellyfin is closed, the public address fails closed with a 502 instead of opening a different route into the house. It is exactly my favourite kind of project: slightly overbuilt, easy to restart, and invisible once it works. The Infrastructure page shows the full path.

JellyfinReverse SSHUnix socketCaddyTLS
โš”๏ธ

Hytale Server + Live Status API

Earlier self-hosting build · currently resting

I built a self-hosted Hytale server in Docker, plus a small Flask service that watched the container and logs to report health, uptime, whitelist status and player activity. Caddy exposed that data at /api/status, /api/mods and /api/whitelist. Both this site and Hazel read the same source, so I never had to maintain two versions of the truth.

The game server is not running on the smaller VPS now. I kept this project here because it is still one of the clearest examples of how I learn: build the whole path, make it observable, wire it into something I already use, then keep the receipts even after the obsession moves on.

HytaleDockerFlaskCaddyPython
Hytale status examples in Discord

๐Ÿ“ธ The site itself

This site is a project too, and the largest one on this page by a distance. A few captures of what it currently looks like. The running record of how it got here is on the changelog.

Screenshot of the lilharper.dev home page, showing the live homelab topology above featured projects.
Home, with the live topology diagram.
Screenshot of the infrastructure page rendered as a network map with colour-coded nodes.
Infrastructure as a network map, live status on the VPS node.
Screenshot of the music page genre galaxy, one star per artist connected by researched links.
The genre galaxy: 150 artists, 215 sourced connections.
Screenshot of the gaming page hours chart, with Steam-measured and estimated figures colour-coded separately.
The hours chart, keeping measured and estimated figures apart.

Everything under Projects

What I have built, what's running right now, and the record of how it got that way.