kaledonya clips: a Discord-friendly clip site on top of Zipline
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
e6f8905377
34 files changed
+2917
No files matched your search
@@ -0,0 +1,223 @@
|
||||
<h1 align="center">kaledonya clips</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Share game clips that actually play inside Discord.</b><br>
|
||||
A small self-hosted clip site on top of <a href="https://github.com/diced/zipline">Zipline</a>, dressed like Steam in 2003.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="MIT license" src="https://img.shields.io/badge/license-MIT-c4b550?style=flat-square">
|
||||
<img alt="Python 3.12" src="https://img.shields.io/badge/python-3.12-3e4637?style=flat-square&logo=python&logoColor=white">
|
||||
<img alt="FastAPI" src="https://img.shields.io/badge/FastAPI-backend-3e4637?style=flat-square&logo=fastapi&logoColor=white">
|
||||
<img alt="Docker Compose" src="https://img.shields.io/badge/docker-compose-3e4637?style=flat-square&logo=docker&logoColor=white">
|
||||
<img alt="Built on Zipline 4.8" src="https://img.shields.io/badge/built%20on-Zipline%204.8-3e4637?style=flat-square">
|
||||
<img alt="No build step" src="https://img.shields.io/badge/frontend-no%20build%20step-3e4637?style=flat-square">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/upload.gif" width="540" alt="Uploading two clips: send, prepare for Discord with a live percentage, then the links appear with Copy buttons">
|
||||
</p>
|
||||
|
||||
## 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.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/library.png" alt="Library: a grid of clips with thumbnails, views and days left"><br><sub><b>Library:</b> only your own clips, with thumbnails, views and how long each one has left</sub></td>
|
||||
<td width="50%"><img src="docs/watch.png" alt="Watch page: the clip in a player with Copy link, Download, Rename and Delete"><br><sub><b>Watch page:</b> the <code>/v/</code> link you share, which gets a proper player in Discord and Telegram</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/login.png" alt="Login screen styled like an old Steam window"><br><sub><b>Login:</b> invite only, with your own Zipline accounts</sub></td>
|
||||
<td width="50%"><img src="docs/upload-done.png" alt="Upload finished: two links with Copy buttons"><br><sub><b>Done:</b> links only show up once they'll play in Discord</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
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](https://github.com/diced/zipline) by diced. The look is a nod to Steam's 2003 client.
|
||||
MIT licensed, see [LICENSE](LICENSE).
|
||||
Reference in new issue
Block a user