Co-Authored-By: Claude Opus 5.5 <[email protected]>
kaledonya clips
Share game clips that actually play inside Discord.
A small self-hosted clip site on top of Zipline, dressed like Steam in 2003.
Why
Discord only shows a video player for a linked file it can download in about 8 seconds. From a home connection that's around 30 MB, while a few minutes of 1080p is hundreds. Post a raw link to a big clip and your friends get a grey box.
So for every big clip, the server makes a small 720p copy that is only used for Discord's player, and it holds the link back until that copy is ready (Discord caches whatever it sees first). The original stays untouched and plays at full quality on the site.
What you get
- Library of your own clips (each account only sees its own), with thumbnails, rename and delete
- Watch page at
/v/<name>: the link you share. Discord, Telegram and others get a proper video embed - Uploads by drag and drop, up to 5 GB, as a three-step install dialog: Send → Prepare for Discord → Link ready, with real progress and time left at each step
- Clean names: spaces become dashes, a taken name gets
-2,-3, ... .mkv→.mp4automatically (stream copy, no quality loss), so OBS recordings play in browsers and Discord- Clips delete themselves after 90 days (configurable)
- Invite only: you make the accounts, nobody can sign up
- The raw file is still at
https://<domain>/<name>and Zipline's own dashboard at/dashboard
How it fits together
browser / Discord
|
Caddy (or a Cloudflare Tunnel)
|-- / /v/* /_site/* POST /upload --> intake
'-- everything else (clips, /dashboard, /api) --> Zipline --> Postgres
intake = this repo (Python/FastAPI + plain HTML/CSS/JS): the site, uploads, ffmpeg remux and Discord copies
Zipline = stores the files, users, expiry and thumbnails
intake never stores passwords. Logging in checks the username and password with Zipline and keeps that user's Zipline token in an HttpOnly cookie, so every request runs as that user.
Requirements
- A Linux machine with Docker and Docker Compose v2
- A domain (or subdomain) you control
- Either open ports 80 and 443 (option A) or a free Cloudflare account (option B)
- CPU for the Discord copies: a 3-minute 1080p clip takes about a minute on 4 threads of a laptop i7
Setup
git clone https://git.userevolt.app/esef/kaledonya-clips.git
cd kaledonya-clips
cp .env.example .env
Edit .env: set SITE_DOMAIN, and fill POSTGRESQL_PASSWORD and CORE_SECRET with two different random
strings (openssl rand -hex 32 makes one). The rest is optional and explained in the file.
Option A: Caddy (open ports, automatic HTTPS)
- Point your domain's DNS (A/AAAA record) at the machine and open ports 80 and 443.
- Keep
COMPOSE_PROFILES=caddyin.env. docker compose up -d --build
Caddy gets a certificate for the domain by itself. Uploads go to the same address, so there's nothing else to set.
Option B: Cloudflare Tunnel (no open ports)
- In Cloudflare Zero Trust → Networks → Tunnels, create a tunnel and copy its token into
TUNNEL_TOKEN. SetCOMPOSE_PROFILES=tunnel. - Add two public hostname routes for your domain, in this order (the first match wins):
- path
^/($|v/|_site/)→http://intake:8000 - no path →
http://zipline:3000
- path
- Uploads can't go through Cloudflare: it refuses request bodies over 100 MB. Give intake a second address
that doesn't go through Cloudflare and set
UPLOAD_URLto it. That can be a VPN such as Tailscale (tailscale serve --bg --https=10000 http://127.0.0.1:3020with aports: ["127.0.0.1:3020:8000"]line added tointake), or a separate subdomain with its own reverse proxy. SetUPLOAD_HINTto tell people what to do when they can't reach it, e.g.Connect to Tailscale to upload.Watching and sharing clips works for everyone either way. - In Cloudflare → Caching → Cache Rules, add a rule for the domain that bypasses the cache. Cloudflare
caches anything ending in
.mp4, which otherwise makes deleted or renamed clips stick around. docker compose up -d --build
First account and friends
- Open
https://<domain>/dashboard. Zipline asks you to create the first account; it becomes the admin. - Make accounts for friends in the Zipline dashboard's user admin.
- Everyone logs in at
https://<domain>/with their Zipline username and password.
Using it
- Drag clips onto the page (or use Upload). When the dialog says Link ready, copy the link and post it.
- Share the
/v/<name>link, not the raw file. That's the one with the Discord player. - If you posted a link to Discord before it said ready and got no player, Discord has cached the broken embed.
Post it again with
?1on the end. - Clips over about 6⅓ minutes get no Discord copy: at that length a ~22 MB file would look too bad to watch. They still play on the site, and Discord shows a plain link.
Uploading from a script or ShareX
POST https://<domain>/upload with the file in a form field named file and your Zipline token (copy it in the
Zipline dashboard's settings) in the Authorization header:
curl -H "Authorization: $ZIPLINE_TOKEN" -F "file=@my clip.mkv" https://clips.example.com/upload
# {"url":"https://clips.example.com/my-clip.mp4","name":"my-clip.mp4","remuxed":true}
With option B this has to go to UPLOAD_URL, not through Cloudflare.
Settings
All in .env; see .env.example for the full list.
| Setting | Default | What it does |
|---|---|---|
SITE_DOMAIN |
required | Your domain, without https:// |
SITE_NAME |
kaledonya clips |
Name in the tab, logo and embeds; its first word is the big login-screen word |
SERVER_NAME |
the server |
"Sending to …" and the status bar |
UPLOAD_URL |
https://SITE_DOMAIN |
Where browsers send uploads |
UPLOAD_HINT |
Can't reach the upload server. |
Shown when an upload can't connect |
CLIP_LIFETIME |
90d |
When clips delete themselves |
INTAKE_CPUS |
4 |
CPU cap for uploads and Discord copies. Lower it if the machine runs hot |
THUMBNAIL_INTERVAL |
5m |
How often Zipline makes library thumbnails for new uploads |
The Discord copy sizes are at the top of intake/app/previews.py: up to 4 minutes → 720p in ~22 MB, longer →
480p in ~18 MB. If your upload speed is well above 4 MB/s you can raise them.
Things to know
- Links are public. Anyone with a link can watch the clip, and names are readable words. Rename a clip to something unguessable if it shouldn't be found.
- Discord copies are made one at a time in the background, at low CPU priority. Each one takes about a third of the clip's length on 4 threads.
- Clips, the database and Caddy's certificates are stored in
./data. intake's scratch space (uploads in progress,.mkvconversion, Discord copies) is theintake-workDocker volume. To back up the database:docker compose exec zipline-db pg_dump -U zipline zipline | gzip > zipline.sql.gz. - Converting a
.mkvbriefly needs about 3× the file size free for theintake-workvolume. - Library thumbnails are made by Zipline every few minutes (
THUMBNAIL_INTERVAL), so a new upload shows a plain tile until then.
Updating
cd kaledonya-clips
git pull
docker compose up -d --build
Your .env, ./data and the intake-work volume are left alone. Compare .env.example with your .env for new settings.
Troubleshooting
| Problem | Fix |
|---|---|
| A link shows no player in Discord | It was posted before the Discord copy was ready, and Discord cached the broken embed. Post it again with ?1 on the end. Wait for Link ready next time. |
| Long clips never get a player | Clips over about 6⅓ minutes get no copy on purpose. They play on the site, and Discord shows a plain link. |
| Uploads fail at 100 MB (option B) | That's Cloudflare's limit. Set UPLOAD_URL to an address that doesn't go through Cloudflare (see Option B, step 3). |
| Upload says "Can't reach the upload server" | The browser can't reach UPLOAD_URL. With a separate upload address (option B), opening <UPLOAD_URL>/health in that browser should show {"ok":true}. |
| Library tiles stay blank | Zipline makes thumbnails every THUMBNAIL_INTERVAL (5 min by default). |
| Caddy has no certificate / browser warns | The domain's DNS must point at this machine and ports 80 and 443 must be reachable. docker compose logs caddy says why. |
| A deleted or renamed clip still shows (option B) | Cloudflare cached it. Add the cache-bypass rule from Option B, step 4. |
| Login says "Too many tries" | 5 wrong passwords from one IP for one username in 10 minutes. Wait 10 minutes. |
| Something else | docker compose logs intake and docker compose logs zipline usually say what went wrong. |
Security notes
Worth knowing before you hand out accounts:
- The login cookie holds the user's Zipline API token (HttpOnly, Secure, SameSite=Lax, 30 days). Logging out deletes the cookie but doesn't change the token. If you think a token leaked, reset it in the Zipline dashboard.
- Links are public by design. Anyone with a clip's link can watch it, and an account can find out whether a
name is taken (an upload gets
-2, a rename says it's used). - Login attempts are limited per IP and username: 5 wrong passwords in 10 minutes and that pair has to wait.
The IP comes from
CF-Connecting-IPorX-Forwarded-For. The Caddyfile strips the Cloudflare header and Caddy replacesX-Forwarded-For, so neither can be faked. If you expose intake's port some other way (a VPN for uploads, say), anyone who can reach it can fake those headers, so only expose it to people you trust. - The no-login routes (
/v/<name>, thumbnails) run ffmpeg/ffprobe on existing clips. At most two run at once, and results are cached. - intake runs as an unprivileged user, and ffmpeg runs at low CPU priority.
Development
cd intake
python -m venv .venv && .venv/bin/pip install -r requirements.txt pytest
.venv/bin/python -m pytest -q
The tests use a fake Zipline, so they don't need Docker. The few that need ffmpeg are skipped without it.
Credits and license
Built on Zipline by diced. The look is a nod to Steam's 2003 client. MIT licensed, see LICENSE.




