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,
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.
Screenshots
5 screenshots
Built with
- PHP 8.2
- SQLite
- JavaScript
- Ollama
- GSAP
- AniList GraphQL API
- OAuth 2.0
- PWA
- NixOS