Sign inSign up

christt105/cinegram-web

By christt105

•Updated 18 days ago

Image
0

937

christt105/cinegram-web repository overview

⁠Cinegram

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.

Preview of the web

It ships as three services orchestrated with Docker Compose, so a full deployment is a single docker compose up.

⁠Key Features

  • Bidirectional Media Transfer: Download files from Telegram directly into your Jellyfin library, or back up existing Jellyfin media to Telegram.
  • Downloads Folder Import: Watches a folder for new video files, identifies each one against TMDB and — once you confirm the guess from Telegram or the web — renames and moves it into the library. See Importing from the downloads folder⁠.
  • Automatic Large File Handling: Circumvents Telegram's 2 GB bot upload limit by splitting larger files into 1.95 GB multi-part archives using store-only 7z compression, rejoining them automatically on download.
  • Metadata & Naming Standardization: Integrates with TMDB to fetch metadata, posters, and standardize filenames/folder structures for Jellyfin.
  • Local Metadata for Non-Official Content: Flag a season whose numbering doesn't match any online provider and Cinegram writes .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⁠.
  • Multi-User Access Control: Restricts bot commands and storage privileges to authorized Telegram user IDs.
  • Web UI & Bot Interface: Manage imports, search your library, and monitor transfer queues via the Vue web dashboard or Telegram chat.

⁠Architecture

Cinegram is a decoupled set of three services:

ServiceStackRole
webVue 3 + Vite + TypeScriptAdmin panel: browse the library, manage download/upload queues, re-identify collections.
backendPython 3.12 + FastAPI + SQLModelSource of truth: REST API, filename parsing, TMDB metadata, task queues (SQLite).
bot-netC# / .NET 8 + WTelegramClientWorker: 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⁠.

⁠Prerequisites

⁠Quick start

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_DIR were replaced by a single MEDIA_ROOT in v2.0.0.

  1. Create a directory and fetch the compose file and the environment template:
    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
    
  2. Edit .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.
  3. Start the stack:
    docker compose up -d
    
  4. Open the web panel at 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.

⁠Build from source

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

⁠Environment variables

All configuration lives in .env (see .env.example for the template).

VariableDescription
JELLYFIN_URLBase 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_TOKENJellyfin 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_IDTelegram api_id from https://my.telegram.org⁠.
TELEGRAM_API_HASHTelegram api_hash from https://my.telegram.org⁠.
TELEGRAM_BOT_TOKENBot token from @BotFather⁠.
TELEGRAM_AUTH_USER_IDTelegram 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_KEYTMDB API key used for metadata lookups.
TMDB_CONTENT_LANGUAGELanguage for titles and overviews (e.g. en-US, es-ES, fr-FR).
MEDIA_ROOTHost path for the media root, bind-mounted whole into bot-net at /data/media so moves between its subfolders stay on one filesystem.
MOVIES_SUBDIRSubdirectory of MEDIA_ROOT holding the movies library (defaults movies), exposed to bot-net at /data/media/${MOVIES_SUBDIR}.
SHOWS_SUBDIRSubdirectory of MEDIA_ROOT holding the shows library (defaults shows), exposed to bot-net at /data/media/${SHOWS_SUBDIR}.
DOWNLOADS_SUBDIRSubdirectory 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_MAPMaps 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_MBOptional 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_ROOTHost 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_DIREnables 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_SUBDIRSComma-separated subdirectories of JELLYFIN_BACKUP_DIR to archive (defaults config,data,plugins); blank archives the whole directory.
JELLYFIN_BACKUP_INTERVAL_HOURSHow often to back up, in hours (defaults 168, i.e. weekly; minimum 1).
JELLYFIN_BACKUP_CHAT_IDTelegram chat backups are sent to (defaults to the owner's chat, the first id in TELEGRAM_AUTH_USER_ID).
JELLYFIN_BACKUP_RETAINHow 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 / PGIDUser/group IDs the backend and bot-net containers run as, so they can write to the host media directories (defaults 1000:1000).
WEB_PORTHost port for the web panel (defaults 5173). Change it if the port is already in use.
BACKEND_PORTHost port for the backend API (defaults 8005). The web container reads it at start.
BOT_NET_PORTHost port for the bot-net worker (defaults 8088). The web container reads it at start.
CINEGRAM_TAGImage 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).

⁠Path mapping

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.

⁠Jellyfin backups

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.

  1. Bind-mount Jellyfin's appdata directory into 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.
  2. By default only 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.
  3. The archive is a gzipped tar, built and sent without ever touching the media library or leaving a copy on disk afterwards.
  4. A container that has never backed up runs one right away; one restarting mid-interval waits out the remainder instead of backing up on every boot, tracked in appdata/bot-net/jellyfin-backup-last-run.
  5. Only the most recent 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.

⁠Importing from the downloads folder

bot-net watches MEDIA_ROOT/${DOWNLOADS_SUBDIR} recursively and imports whatever video files appear in it, without you having to name anything by hand:

  1. Detection. A new video file is only picked up once its size has stopped growing, so a file still being written isn't imported half-finished. Renames and deletions are followed too, including a whole folder renamed at once.
  2. Identification. The backend parses the filename and looks it up on TMDB, producing a guess: media type, title, year, and season/episode for a series, each with a confidence.
  3. Confirmation. The bot sends you a Telegram message with the guess and two buttons, Confirm and Correct. The same file also shows up in the web panel's Downloads section, which additionally does batch actions over several files at once. Correcting means giving the right TMDB id (and season/episode), from either interface.
  4. Move. On confirmation the file is renamed to Jellyfin's convention and moved into the movies or shows subdirectory. Because the library and the downloads folder are subfolders of the same 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.

⁠Local metadata for non-official content

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 ports

ServiceHost portContainer 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.

⁠Security

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.

⁠Forwarding does not copy the file

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:

  • You don't own most of what you "have". A forwarded file stays tied to whoever originally uploaded it. The underlying blob can be removed by Telegram for a variety of reasons (copyright claims among them) on public groups and channels, and it is not documented (or safe to assume) what happens to a private pointer when that happens. Re-uploading a file through Cinegram is the only way to make it independent, since that creates a genuinely new blob.
  • Hiding the sender when forwarding makes this worse, not better. It does not change how the file is stored, it only deletes the one piece of information (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.

⁠Bot commands

Once the containers are up, control the worker from Telegram (as the user in TELEGRAM_AUTH_USER_ID):

CommandDescription
/start, /helpWelcome message and list of available commands.
/healthReport the bot's health.
/add [search query]Search TMDB and add a movie or series to the database.
/importImport local Jellyfin media into Telegram and the database.
/search <query>Search the local library (accent-insensitive).
/movies, /seriesList all registered movies / series.
/movie <id|tmdbid-id>Show a movie's details.
/serie <series-id>Show a series' details.
/orphansList collections still missing TMDB identification.
/queueShow active upload and download transfers.
/reidentifyRe-run TMDB identification for every unresolved watched file.
/authShow whether a Telegram user account session is active.
/backupSend a backup of the database.
/versionShow the bot version.

⁠Telegram user account (optional)

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".

  1. From the directory where the stack runs (the one holding your .env):
    docker compose run --rm -it bot-net auth
    
  2. Enter the phone number, then the verification code Telegram sends you, and the two-factor cloud password if the account has one.
  3. Restart the worker so it picks up the new session:
    docker compose restart bot-net
    
  4. Check it from Telegram with /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.

⁠Data & Persistence

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.

⁠System dependencies

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.

⁠Troubleshooting

  • Permission errors writing to media folders: Ensure PUID and PGID in .env match the Linux user/group that owns MEDIA_ROOT.
  • Uploads to Telegram fail with 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.
  • "Incomplete login attempt" from Telegram when authenticating a user account: the verification code was typed into a Telegram chat, which invalidates it. Log in from the server terminal instead — see Telegram user account⁠.
  • Web UI settings or backend port updates not taking effect: the web reads them at container start. Recreate the web container after changing .env:
    docker compose up -d web
    

⁠Disclaimer

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

Tag summary

Content type

Image

Digest

sha256:3ccbfe0a3…

Size

28.2 MB

Last updated

18 days ago

docker pull christt105/cinegram-web