Cinegram is a self-hosted platform that bridges a Jellyfin media library with Telegram. It automates downloading movies and series from Telegram, processes and renames them to Jellyfin's naming convention, and can also back up existing Jellyfin files to Telegram.

It ships as three services orchestrated with Docker Compose, so a full deployment is a single docker compose up.
7z compression, rejoining them automatically on download..nfo sidecars next to the files, so Jellyfin reads titles and plots from disk instead of reconciling them against an unrelated online entry. See Local metadata.Cinegram is a decoupled set of three services:
| Service | Stack | Role |
|---|---|---|
web | Vue 3 + Vite + TypeScript | Admin panel: browse the library, manage download/upload queues, re-identify collections. |
backend | Python 3.12 + FastAPI + SQLModel | Source of truth: REST API, filename parsing, TMDB metadata, task queues (SQLite). |
bot-net | C# / .NET 8 + WTelegramClient | Worker: polls tasks, transfers files over Telegram (MTProto), splits/joins with 7z, reads metadata with ffprobe. |
User ─▶ web (Vue) ─▶ backend (FastAPI) ─▶ SQLite / TMDB
▲
│ polls tasks & reports status
bot-net (.NET) ─▶ Telegram (MTProto)
─▶ Host disk (Jellyfin media)
The bot-net worker authenticates as a Telegram bot, which caps file transfers at 2 GB; files larger than that are split into 1.95 GB parts with 7z (store-only) on upload and rejoined on download. Optionally it can also log in as a Telegram user account; if that account has Premium, parts go up to 3.9 GB — see Telegram user account.
api_id / api_hash from https://my.telegram.org) and a bot token from @BotFather.Cinegram runs from prebuilt images on the GitHub Container Registry, so a deployment needs only two files — no clone, no build.
Upgrading an existing deployment? Check MIGRATIONS.md for breaking changes and the steps to apply them — most recently,
IMPORT_MOVIES_DIR/IMPORT_SHOWS_DIRwere replaced by a singleMEDIA_ROOTin v2.0.0.
mkdir cinegram && cd cinegram
curl -O https://raw.githubusercontent.com/christt105/cinegram/main/docker-compose.yml
curl -o .env https://raw.githubusercontent.com/christt105/cinegram/main/.env.example
.env with your own values. In particular, point MEDIA_ROOT at the host directory containing your media library (bind-mounted whole into bot-net), and adjust MOVIES_SUBDIR / SHOWS_SUBDIR if Jellyfin's movies and shows folders aren't named movies / shows under it.docker compose up -d
http://<host>:5173.Docker pulls the web, backend, and bot-net images from ghcr.io/christt105/cinegram-*. To update later, run docker compose pull && docker compose up -d. Pin an exact release by setting CINEGRAM_TAG=v1.2.0 in .env, or pin to a major line (e.g. CINEGRAM_TAG=v1) to get patch/minor updates automatically while staying clear of breaking changes across major versions — see MIGRATIONS.md before crossing one. Defaults to latest.
Contributors who want to run their own changes can build the images locally instead of pulling them:
git clone https://github.com/christt105/cinegram.git
cd cinegram
cp .env.example .env # then edit
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
All configuration lives in .env (see .env.example for the template).
| Variable | Description |
|---|---|
JELLYFIN_URL | Base URL of your Jellyfin server (e.g. http://your-jellyfin-host:8096). Read by the web container at start; if left empty the web falls back to the browser host on port 8096. Also used by bot-net to identify series it moves and for its startup path check (see Path mapping). |
JELLYFIN_TOKEN | Jellyfin API token, used by the web client and by bot-net to force the TMDB id of a confirmed series and for its startup path check. Without it the bot relies on the id tag in the folder name alone, and the path check is skipped. |
TELEGRAM_API_ID | Telegram api_id from https://my.telegram.org. |
TELEGRAM_API_HASH | Telegram api_hash from https://my.telegram.org. |
TELEGRAM_BOT_TOKEN | Bot token from @BotFather. |
TELEGRAM_AUTH_USER_ID | Telegram user ID allowed to command the bot. Accepts a comma-separated list to authorize several users (e.g. 123,456); the first ID is the owner whose chat stores the media. |
TMDB_API_KEY | TMDB API key used for metadata lookups. |
TMDB_CONTENT_LANGUAGE | Language for titles and overviews (e.g. en-US, es-ES, fr-FR). |
MEDIA_ROOT | Host path for the media root, bind-mounted whole into bot-net at /data/media so moves between its subfolders stay on one filesystem. |
MOVIES_SUBDIR | Subdirectory of MEDIA_ROOT holding the movies library (defaults movies), exposed to bot-net at /data/media/${MOVIES_SUBDIR}. |
SHOWS_SUBDIR | Subdirectory of MEDIA_ROOT holding the shows library (defaults shows), exposed to bot-net at /data/media/${SHOWS_SUBDIR}. |
DOWNLOADS_SUBDIR | Subdirectory of MEDIA_ROOT watched by bot-net's downloads-import FileSystemWatcher (defaults downloads), exposed to bot-net at /data/media/${DOWNLOADS_SUBDIR} via DOWNLOADS_DIR. |
JELLYFIN_PATH_MAP | Maps the paths Jellyfin reports onto bot-net's own paths, as jellyfin_path:container_path pairs separated by commas. Only needed when Jellyfin sees the library under different paths than the host — see Path mapping. |
UPLOAD_SPLIT_LIMIT_MB | Optional override for the part size used when splitting large files. Defaults to 1950 (bot API), or 3900 when a Premium user account session is active. |
JELLYFIN_APPDATA_ROOT | Host path to Jellyfin's own appdata directory, bind-mounted read-only into bot-net at /data/jellyfin-appdata. Only needed for periodic backups — see Jellyfin backups. |
JELLYFIN_BACKUP_DIR | Enables periodic Jellyfin backups when set, to the path holding Jellyfin's state inside bot-net (normally /data/jellyfin-appdata, matching JELLYFIN_APPDATA_ROOT). Unset by default, backups off. |
JELLYFIN_BACKUP_SUBDIRS | Comma-separated subdirectories of JELLYFIN_BACKUP_DIR to archive (defaults config,data,plugins); blank archives the whole directory. |
JELLYFIN_BACKUP_INTERVAL_HOURS | How often to back up, in hours (defaults 168, i.e. weekly; minimum 1). |
JELLYFIN_BACKUP_CHAT_ID | Telegram chat backups are sent to (defaults to the owner's chat, the first id in TELEGRAM_AUTH_USER_ID). |
JELLYFIN_BACKUP_RETAIN | How many of the most recent backups to keep in the chat; older ones are deleted as new ones land (defaults 4). 0 or negative keeps every backup ever sent. |
PUID / PGID | User/group IDs the backend and bot-net containers run as, so they can write to the host media directories (defaults 1000:1000). |
WEB_PORT | Host port for the web panel (defaults 5173). Change it if the port is already in use. |
BACKEND_PORT | Host port for the backend API (defaults 8005). The web container reads it at start. |
BOT_NET_PORT | Host port for the bot-net worker (defaults 8088). The web container reads it at start. |
CINEGRAM_TAG | Image tag to deploy (defaults latest; pin to an exact release like v1.2.0, to a major line like v1 to auto-track its patches/minors, or to pr-18 to try a pull request). |
When you back up media from Jellyfin to Telegram, Jellyfin hands bot-net the path of the file as Jellyfin sees it. bot-net then has to open that file through its own bind mount (/data/media/${MOVIES_SUBDIR} and /data/media/${SHOWS_SUBDIR}).
If Jellyfin runs directly on the host, or in a container that mounts the library at the same paths as the host, nothing to do: the MEDIA_ROOT/MOVIES_SUBDIR/SHOWS_SUBDIR prefixes already match what Jellyfin reports.
If Jellyfin runs in its own container with different mount points, they don't match and uploads fail with Local file or directory not found. Set JELLYFIN_PATH_MAP to bridge the two views. For a Jellyfin that mounts the library at /media/library/movies and /media/library/shows:
JELLYFIN_PATH_MAP=/media/library/movies:/data/media/movies,/media/library/shows:/data/media/shows
Each entry is path_as_jellyfin_reports_it:path_inside_bot-net, and the right-hand side is one of bot-net's two library subdirectories. Entries are tried in order, before falling back to MEDIA_ROOT/MOVIES_SUBDIR/SHOWS_SUBDIR. To find the left-hand side, look at any item's path in Jellyfin (Administration → the item → the file path), or read it off the Translated path: line in docker compose logs bot-net after a failed upload.
You don't have to wait for a failed upload to find out whether the mapping is right. At startup bot-net asks Jellyfin where its movie and show libraries live, translates each location the same way an upload would, and logs the outcome:
docker compose logs bot-net | grep "library path check"
A healthy deployment reports Jellyfin library path check: 2/2 locations resolve inside bot-net. Anything else prints one warning per library naming the reported path, what it translated to and the JELLYFIN_PATH_MAP entry that would fix it. Libraries of other kinds (music, photos) are not checked, since bot-net never reads from them. The check needs JELLYFIN_URL and JELLYFIN_TOKEN to be set; without them, or if Jellyfin is unreachable, it logs a warning saying so and the worker starts as usual.
bot-net can periodically archive Jellyfin's own state, not the media library, and send it through the bot so a copy lives somewhere other than the machine running Jellyfin. Off by default; set JELLYFIN_BACKUP_DIR to turn it on.
bot-net by setting JELLYFIN_APPDATA_ROOT to its host path, then point JELLYFIN_BACKUP_DIR at /data/jellyfin-appdata to match. See .env.example.config, data and plugins are archived (JELLYFIN_BACKUP_SUBDIRS). metadata is left out: it holds posters, fanart and .nfo files Jellyfin re-fetches from TMDB/TheTVDB on demand, and can dwarf everything else. Add it to JELLYFIN_BACKUP_SUBDIRS, or blank the variable, to back up the whole directory anyway.appdata/bot-net/jellyfin-backup-last-run.JELLYFIN_BACKUP_RETAIN backups (default 4) are kept in the chat: sending a new one deletes the oldest beyond that count, so a backup left running for years doesn't fill the chat history. Set it to 0 or a negative number to keep every backup ever sent instead.bot-net watches MEDIA_ROOT/${DOWNLOADS_SUBDIR} recursively and imports whatever video files appear in it, without you having to name anything by hand:
MEDIA_ROOT bind mount, this is a rename on one filesystem rather than a copy.If the guesses are wrong across the board — TMDB was unreachable when the files were detected, say — /reidentify re-runs the identification for every file still unresolved.
A file removed from disk before you action it is marked as such instead of leaving a dead button behind, and one that was already moved is never re-imported.
Some series are numbered in a way no online provider recognises: fan edits, re-cuts, compilations, anything whose episode order doesn't match TheTVDB or TMDB. Jellyfin would keep trying to reconcile those files against an unrelated online entry and mislabel them.
Mark such a season as Local metadata (non-official) in the web panel (series detail → the season's checkbox) and Cinegram stops relying on the online entry for it: as each episode is downloaded from Telegram, it writes Kodi/Jellyfin-compatible .nfo sidecars — tvshow.nfo at the series root, season.nfo in the season folder, and one .nfo per episode file. Episode titles become editable per episode in that same view, and what you type is what ends up in the sidecar and, therefore, in Jellyfin.
| Service | Host port | Container port |
|---|---|---|
web | ${WEB_PORT:-5173} | 80 |
backend | ${BACKEND_PORT:-8005} | 8000 |
bot-net | ${BOT_NET_PORT:-8088} | 8080 |
All three host ports are configurable in .env, so you can move any of them if it clashes with something else already running on the host. At container start the web injects JELLYFIN_URL / JELLYFIN_TOKEN / BACKEND_PORT / BOT_NET_PORT into the browser (via /config.js), so the app talks to the services on whatever ports you choose. After changing them, docker compose up -d is enough — no rebuild.
The web service is a static bundle served by nginx, with the runtime config written on startup by web/docker-entrypoint.sh. A web/Dockerfile.dev (Vite dev server with hot reload) is kept for local development.
Cinegram has no built-in authentication on the web panel or the backend API: anyone who can reach those ports can browse and modify the library. Only the Telegram bot is access-controlled (via TELEGRAM_AUTH_USER_ID). Do not expose the web or backend ports directly to the internet. Keep them on your LAN and reach them through a VPN, or put them behind a reverse proxy that adds authentication and TLS.
Most files end up in Cinegram by being forwarded to the bot from another chat, group, or channel, rather than uploaded fresh. Telegram forwarding does not copy the underlying blob: it only points a new message at the same one the original sender uploaded. Two consequences follow from that:
fwd_from) that would tell you what the file depends on. Forward normally.Every file row stores document_id (Telegram's identifier for the underlying blob, identical across every forward of it) and, when the upload was a forward, fwd_from_type / fwd_from_id / fwd_from_name / fwd_from_hidden (who it came from). These are populated automatically for new uploads. Existing rows from before this was tracked stay empty until backfilled with backend/scripts/backfill_forward_origin.py, which re-fetches each historical message from Telegram to fill both columns in; backend/scripts/forward_origin_report.py then summarizes exposure by source, so you can see how much of your archive depends on channels or chats you don't control.
Once the containers are up, control the worker from Telegram (as the user in TELEGRAM_AUTH_USER_ID):
| Command | Description |
|---|---|
/start, /help | Welcome message and list of available commands. |
/health | Report the bot's health. |
/add [search query] | Search TMDB and add a movie or series to the database. |
/import | Import local Jellyfin media into Telegram and the database. |
/search <query> | Search the local library (accent-insensitive). |
/movies, /series | List all registered movies / series. |
/movie <id|tmdbid-id> | Show a movie's details. |
/serie <series-id> | Show a series' details. |
/orphans | List collections still missing TMDB identification. |
/queue | Show active upload and download transfers. |
/reidentify | Re-run TMDB identification for every unresolved watched file. |
/auth | Show whether a Telegram user account session is active. |
/backup | Send a backup of the database. |
/version | Show the bot version. |
Transfers run over the bot API by default, which caps each part at 1.95 GB. If you also log in with a personal Telegram account, bot-net uses it for uploads and downloads instead; with Telegram Premium the part size goes up to 3.9 GB, which means fewer 7z parts per file and faster transfers.
Files are stored in the chat between you and the bot either way, and every one of them is recorded under the bot's own message id. The account's id for the same message is kept alongside it purely as a shortcut, so an expired or deleted session only costs speed: downloads fall back to the bot and the archive stays readable.
The login has to be done from a terminal on the server, not from the Telegram chat. Telegram's anti-scam protection invalidates any login code that its servers see your account send in a message, so a code typed into the bot is rejected with "Incomplete login attempt … the code was shared by your account previously".
.env):
docker compose run --rm -it bot-net auth
docker compose restart bot-net
/auth.The session is written to ./appdata/bot-net/user_client.session and reused on every start, so this is a one-off. Run the same command again to re-authenticate — the current session is only replaced once the new login succeeds. Deleting that file reverts the worker to the bot API limits.
All persistent application state is stored on the host under ./appdata:
./appdata/backend: SQLite database storing library metadata, task queues, and media indexes../appdata/bot-net: Telegram session state (bot and, if configured, user account) and worker runtime cache.Make sure to back up the ./appdata directory when migrating servers or updating containers.
bot-net shells out to 7z (from p7zip-full) for multipart split/join and to ffprobe (from ffmpeg) for technical metadata. Both are installed inside the bot-net Docker image, so no extra host setup is required when running with Docker Compose. If you run the worker outside Docker, make sure 7z and ffprobe are on your PATH.
PUID and PGID in .env match the Linux user/group that owns MEDIA_ROOT.Local file or directory not found: Jellyfin reports the file under a path bot-net cannot resolve. Set JELLYFIN_PATH_MAP — see Path mapping. The startup log says whether the mapping resolves, so check it there first: docker compose logs bot-net | grep "library path check".JELLYFIN_PATH_MAP changes seem to have no effect: the containers keep the environment they were created with, so editing .env is not enough. Recreate bot-net with docker compose up -d bot-net and confirm the new value took with the startup path check above..env:
docker compose up -d web
Cinegram is an open-source tool built for personal media management and self-hosted server administration. It does not host, stream, or distribute copyright-protected material. Users are solely responsible for ensuring that their deployment and file transfers comply with applicable local laws, copyright regulations, and the Terms of Service of ext
Content type
Image
Digest
sha256:3ccbfe0a3…
Size
28.2 MB
Last updated
18 days ago
docker pull christt105/cinegram-web