Files

12 KiB
Raw Permalink Blame History

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.

MIT license Python 3.12 FastAPI Docker Compose Built on Zipline 4.8 No build step

Uploading two clips: send, prepare for Discord with a live percentage, then the links appear with Copy buttons

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.

Library: a grid of clips with thumbnails, views and days left
Library: only your own clips, with thumbnails, views and how long each one has left
Watch page: the clip in a player with Copy link, Download, Rename and Delete
Watch page: the /v/ link you share, which gets a proper player in Discord and Telegram
Login screen styled like an old Steam window
Login: invite only, with your own Zipline accounts
Upload finished: two links with Copy buttons
Done: links only show up once they'll play in Discord

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 → .mp4 automatically (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)

  1. Point your domain's DNS (A/AAAA record) at the machine and open ports 80 and 443.
  2. Keep COMPOSE_PROFILES=caddy in .env.
  3. 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)

  1. In Cloudflare Zero Trust → Networks → Tunnels, create a tunnel and copy its token into TUNNEL_TOKEN. Set COMPOSE_PROFILES=tunnel.
  2. Add two public hostname routes for your domain, in this order (the first match wins):
    1. path ^/($|v/|_site/) → http://intake:8000
    2. no path → http://zipline:3000
  3. 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_URL to it. That can be a VPN such as Tailscale (tailscale serve --bg --https=10000 http://127.0.0.1:3020 with a ports: ["127.0.0.1:3020:8000"] line added to intake), or a separate subdomain with its own reverse proxy. Set UPLOAD_HINT to 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.
  4. 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.
  5. docker compose up -d --build

First account and friends

  1. Open https://<domain>/dashboard. Zipline asks you to create the first account; it becomes the admin.
  2. Make accounts for friends in the Zipline dashboard's user admin.
  3. 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 ?1 on 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, .mkv conversion, Discord copies) is the intake-work Docker volume. To back up the database: docker compose exec zipline-db pg_dump -U zipline zipline | gzip > zipline.sql.gz.
  • Converting a .mkv briefly needs about 3× the file size free for the intake-work volume.
  • 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-IP or X-Forwarded-For. The Caddyfile strips the Cloudflare header and Caddy replaces X-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.