All projects

Own product · 2026 – present Project 1 of 20

AniMood

One good anime for tonight, picked by your mood and taste.

Visit the site

Verified site status: Up, 69 ms response,

AniMood landing page with the headline Tonight, one good anime and a sign-up form

At a glance

Role
Design, development and operations
Period
2026 – present
Status
Live
Technologies
9 technologies

Most anime sites hand you a catalogue. AniMood hands you tonight: you set the mood, go through a short stack of cards and pass, save or pick each anime. Those choices and a linked AniList list make up your taste, which ranks the next cards, and a notebook remembers how each pick went. I designed, built and run the app on my own server – from the database schema to the nightly backups and the privacy policy.

Why it is technically interesting

  • Recommendations that explain themselves. A custom ranking weighs genres and themes from your notes and your AniList list, with older notes slowly losing weight. The deck keeps itself varied (similar titles never follow each other), roughly one card in six comes from outside your usual taste, and cards show why they made it into the deck.
  • Seasons first. A second season or a sequel film is only offered to someone who finished what comes before it; otherwise the deck offers the start of the series. The order comes from AniList relations, filled in from the open anime-offline-database (ODbL), which is imported once a month – and refused as a whole if the data fails validation.
  • Community recommendations as a graph. Titles that fans of your favourites recommend on AniList move a few places up the deck – never into first place and never two in a row. The graph steers the stack; it does not take it over.
  • AniList in both directions. Changes in the notebook are queued and written to AniList, and changes made on AniList (for example in the Taiga app) come back to the notebook. In a conflict the newer change wins, so an older state never overwrites a newer one.
  • Eight languages (English, Czech, Slovak, German, Polish, Spanish, French and Japanese) with a custom message format for plurals and grammatical gender that works the same in PHP and JavaScript. Even the playful "taste titles" agree in gender in every language.
  • A Wrapped at the end of each season: the story of what you watched is computed when you open it, from the notebook and the cache. Nothing is stored for it and nothing is sent anywhere.

Obstacles and how I solved them

  • SQLite under concurrent writes. In WAL mode, a transaction that has already read cannot take the write lock if someone else wrote in the meantime – SQLite answers "database is locked" at once and waiting (busy_timeout) does not help. In a load test with twelve concurrent users, roughly one swipe in ten failed. Writes that read first now start with BEGIN IMMEDIATE, and a test checks this against concurrent writers in several processes. A second trap: an unfinished cursor keeps an old snapshot of the database, so a write after a network call to AniList failed. Cursors are now closed before every slow operation.
  • AniList API limits. Every user and every feature draws from one shared request budget, and a 429 response pauses calls for the whole server for as long as AniList asks. Whatever it can, the app takes from the cache and the open database instead of the API.
  • A small local model cannot do numbers. The qwen2.5:7b model could not reliably set sliders that run from negative to positive. It now only picks from fixed categories (low, medium or high energy, for example), the server works out the numbers, and the model has to back every change with a quote from what the person wrote – no quote, no change. The confirmation people see is written by the server, not the model.
  • A Content Security Policy without exceptions. The strict CSP allows no inline styles, so the browser drops style attributes in HTML inserted by scripts. Cover colours, sizes and animations are set through the CSSOM instead, which the CSP allows.
  • The missing voice actors. AniList returned empty voice actors for characters unless the query also asked for the character itself, so the whole catalogue was stored without them. Once the cause was found, the queries were fixed and a nightly job filled in the missing data.
  • Translations that did not sound natural. The first translations read like word-for-word renderings of the English. All seven languages went through a review with a glossary for each language and rules for genres in the middle of a sentence.

What you don't see

  • Privacy by design. An account needs no email address: access is recovered with one-time codes that are stored only as hashes, just like passwords (Argon2id). By default the language model runs on the same server, so a mood description never leaves it, and a prompt never gets a name, an identifier or sensitive themes.
  • GDPR in the code, not just in the policy. Account export and deletion are guarded by a test that fails whenever a new table is missing from the inventory of personal data. Data has set retention periods, and the daily maintenance deletes it once they run out.
  • Abuse protection. Sign-in counts only failed attempts, and strangers cannot use them to lock the owner out. Rate limits work with IP addresses hashed with a secret key, so the database holds no readable addresses. Names that imitate existing accounts or the operator are refused. Text from other members is cleaned of invisible characters that flip the direction of writing when it is saved – before that, a single invalid byte stored in a message could break the recipient's whole app.
  • Taste Match is optional and for adults only. Photos are re-encoded (EXIF metadata, location included, is gone) and go through moderation, everyone sees others under their own encrypted aliases, and every moderation decision names the rule that was broken.
  • Operations. A nightly database backup with an integrity check and a test restore from the finished archive, kept for 30 days. Maintenance runs only after the response has been sent, so nobody waits for it. Compression and long caching of versioned files cut the first load from 2.4 MB to 0.68 MB. Sixteen test suites cover recommendations, season order, localisation, query performance, abuse and GDPR.

Technology

  • PHP 8.2 with no framework and no Composer packages – no third-party dependencies to watch and update.
  • SQLite in WAL mode – one database in a file, online backups without downtime and a versioned schema (now version 15 with 38 tables) that brings itself up to date on start.
  • Plain JavaScript and GSAP – animations through a thin layer that respects reduced motion; the app can be installed as a PWA.
  • Ollama with qwen2.5:7b on the server's graphics card, optionally the Claude API with a monthly spending cap. The free-text helpers are admin-only for now.
  • AniList GraphQL API and OAuth 2.0 – linking an account, reading the list and optionally writing back.
  • anime-offline-database (ODbL) – season order without extra API requests.
  • NixOS and systemd timers – nightly backups, the monthly import of the open database and the voice actor backfill.

Built with

  • PHP 8.2
  • SQLite
  • JavaScript
  • Ollama
  • GSAP
  • AniList GraphQL API
  • OAuth 2.0
  • PWA
  • NixOS

Keyboard shortcuts.

Keyboard shortcuts for this site. They stay quiet while you type into a form.

This site

?
This help
g
12-column grid, components and page metrics
d
Appearance: system, light, dark, Linkin Park
Ctrl P
Print the CV instead of the website
Esc
Close the dialog or the grid

The blue dot after the name can be grabbed and thrown.

Linkin Park

l setlist lp
The setlist, songs by album
papercut
The page tears like paper
numb
The site goes numb for a few seconds
faint
The page faints and comes round
meteora
A meteor shower and a direct hit on the dot after the name
fromzero emily
The site fades down to zero and is reborn, with thanks to Emily Armstrong
concert koncert
Concert: the site runs through the colours of every album
castleofglass
The page turns to glass, cracks and shatters
iridescent
Headings and dots shimmer in rainbow colours
breakingthehabit
Whatever is on screen falls apart and snaps back
intheend
End credits, like at the cinema
victim morethevictim
The site breaks up in a glitch and stays distorted (until a reload, another word or Esc)
finalmasquerade
A masquerade mask over the whole site, the camera flies through an eye
lost
Off to the 404 page

Type the words anywhere outside the form; case and spaces do not matter. With reduced motion you get a short note instead.

On a phone, five taps on the blue dot after the name open the setlist.