Work / Automation Production
Home Server Bot: running my media library from a chat
Home Server Bot is a Telegram bot that runs my home media library: it adds TV shows and films from a one-line message, reports what is playing and what is filling the disks, and uses Claude to search the library in plain English. It runs around the clock on a home server.
Demonstration data. Invented data.
Type “Lantern Row S02E03” into Telegram and the bot finds the show, notices there are two with that name and asks which one you mean. Type /movie Midnight Harbour and it does the same for films, then adds your pick to Radarr and starts the search. The bot runs my home media library from a chat window, and much of what I am proud of in it is what keeps a small bot like that safe and alive.
you [chat 1001] /movie Midnight Harbour
bot Found multiple matches - reply with the number:
1. *Midnight Harbour** (2019)
2. *Midnight Harbour* (1998)
you [chat 1001] 9
bot Please reply with a number between 1 and 2.
you [chat 1001] 1
bot Added *Midnight Harbour** (2019) to Radarr!
Searching for the movie now...
Figure 1. A log of the bot’s messages while adding a film, using invented titles. It asks which match you mean, rejects an out-of-range reply and then adds your pick to Radarr. The asterisk in the first title is deliberate: it is the character that once silenced the bot, and the next sections show why.
At a glance
- Status: production. It runs around the clock on a home server.
- Platform: a Telegram bot written in Python, started by a Windows scheduled task.
- Stack: Python and the pyTelegramBotAPI library, talking to the Sonarr, Radarr and Plex interfaces and to the Anthropic API.
- Built with: Claude Code and Codex.
- AI inside the product: yes, in three features: plain-English library search, personalised recommendations and deciding who to tell when a new download lands. The model is Claude Haiku 4.5. A model never decides what gets added or deleted: the commands are ordinary code, and recommendations only add something when I press a button.
A library you can message
You message it like a friend with admin rights. A show name adds the whole series. S02E03, 2x03 or “Season 2” narrows it to one episode or one season. /movie does films, and /remove and /removemovie take things away after a confirmation button. When a name matches several titles, the bot lists up to five and remembers your pending choice for five minutes, and it keeps that memory in a file so a restart does not lose it.
The rest reports on the library. /storage shows disk space on the Sonarr and Radarr drives, /now shows who is streaming on Plex, /watched and /stats show history and hours per person, and /devices lists the devices that have connected. Once a week it looks for shows unwatched for 90 days and films unwatched for a year, and asks the owner about each one, with buttons to keep it, remove it from the library, or remove it and its files. Every ten minutes it checks for new downloads and tells the household members who would most likely want them.
/find a cosy mystery by the sea hands your library listing to Claude and returns up to five matches with their genres and whether you have watched them. /recommend does the same for new titles, from a person’s watch history.
Asking the library in plain English
/find is the feature that needs a model. Below, Claude Haiku 4.5 searches an invented library of ten titles. The model replies with only the index numbers of up to five matches, and the code turns those into the reply, so it cannot invent a title that is not in the library.
you /find a cosy mystery by the sea
bot Searching your library for: _a cosy mystery by the sea_...
bot *Found 3 matches:*
📺 *Copper Tides* (2023) · 👀 unwatched
_Mystery, Drama_
📺 *Lantern Row* (2021) · 👀 unwatched
_Crime, Drama_
📺 *Salt and Ledger* (2019) · ✅ watched
_Comedy_
you /find something short and funny I haven't watched
bot Searching your library for: _something short and funny I haven't watched_...
bot *Found 3 matches:*
📺 *The Long Inventory* (2022) · 👀 unwatched
_Drama, Comedy_
📺 *Copper Tides* (2023) · 👀 unwatched
_Mystery, Drama_
📺 *Salt and Ledger* (2019) · ✅ watched
_Comedy_
Figure 2. Two /find searches against an invented ten-title library. Each reply carries the watched flag, genres and year, and the picks vary from run to run.
Safe by design, and kept alive
A bot that controls a library has to be hard to lock out and hard to let in. Three incidents shaped the code, and each one is in the project’s own status log with its cause.
Build decision. Authorisation fails closed. A review on 17 August found that an empty or mistyped list of allowed chats made the check return “yes” for everybody. It now returns “no” and logs an error, and a reload from a broken settings file keeps the old list instead of installing an empty one. The same review found four button handlers with no check at all. They now carry the same guard, and the tests fail against the old code.
The film that silenced the bot. On the evening of 17 August a /movie search got no reply. Telegram had returned a 400 error, “can’t parse entities”, because the bot formats its messages with Markdown and one film’s title ended in an asterisk, which opens a bold span that never closes. Telegram rejects the whole message and the sender sees nothing. The same pattern existed in 49 places. The fix was one wrapper around the two send functions: on that specific error it resends the message as plain text, and it re-raises every other error so a real outage still shows. You can see it in Figure 1, where the bot answers even though the title would have broken the message. The log recorded the plain-text resend twice when the original film was searched on the live bot.
The two-day silence. On 1 October the bot stopped and stayed down for two days. It was not a code fault. The scheduled task only started at logon and only ran while a person was logged on, so the end of an interactive session killed it and nothing restarted it. The task now starts at boot and runs whether anyone is logged on or not, and the bot sends the owner a “Bot started.” message on every start, so an unexpected restart is visible.
Under the hood
I built the bot with Claude Code and Codex as a Python application, deployed it to my home server and run it from there. The whole bot is one Python file of about 2,160 lines. Telegram messages arrive through the library’s long polling. Handlers call Sonarr, Radarr and Plex over their web interfaces with a ten-second timeout, and two background threads handle the download notifications and the weekly cleanup. Four dependencies are pinned to the versions running in production, so a change to one is a deliberate act.
| Layer | What I used |
|---|---|
| Interface | Telegram, via the pyTelegramBotAPI library and long polling |
| Language and runtime | Python, one file of about 2,160 lines, with two background threads |
| Integrations | Sonarr, Radarr and Plex web interfaces (ten-second timeout), Anthropic API |
| Data | Small state files, such as the pending-choice memory that survives a restart |
| Runtime AI | Claude Haiku 4.5: library search, recommendations, deciding who to notify |
| Hosting | A home server, started by a Windows scheduled task at boot |
| Tests | Two offline tests: the plain-text resend and fail-closed authorisation |
Never run the development copy. The development folder shares the production bot’s Telegram token, so starting it locally would put two programs fighting over every message. Deployment is two commands on two machines. The first, on the development machine, checks the syntax, runs the tests and stages the file with a hash beside it. The second, on the server, re-checks that the staged file parses, refuses to continue if it is already live, backs up the running file, swaps it in, restarts the task and reports whether “Bot started” appeared. Both scripts are kept to plain ASCII, because an em dash saved without a byte-order mark stopped them parsing in the Windows PowerShell on the server.
What it shows
A small tool can be run properly. The bot has fail-closed authorisation, a wrapper that turns one rejected message into a plain-text resend, a deploy that refuses to continue when something looks wrong, and a visible “Bot started” on every restart.