commit e6f89053772ca53323387c84a32a8931ead1dce7 Author: esefxdz Date: Wed Oct 7 20:07:49 2026 +0300 kaledonya clips: a Discord-friendly clip site on top of Zipline Co-Authored-By: Claude Opus 5.5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a51c190 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# Copy to .env and fill in. Never commit .env. + +# Which front door to run: "caddy" (open ports 80/443, automatic HTTPS) or "tunnel" (Cloudflare Tunnel) +COMPOSE_PROFILES=caddy + +# Your domain, without https://. With Caddy, point its DNS A/AAAA record at this machine first. +SITE_DOMAIN=clips.example.com + +# Two long random strings. Make each with: openssl rand -hex 32 +POSTGRESQL_PASSWORD= +CORE_SECRET= + +# --- optional --- + +# Name shown in the tab, the logo and Discord embeds. Its first word is the big word on the login screen. +SITE_NAME=kaledonya clips +# Shown in the upload dialog and status bar: "Sending to ", "connected · " +SERVER_NAME=the server + +# Where the browser sends uploads. Leave unset to use https://SITE_DOMAIN (right for Caddy). +# Behind a Cloudflare Tunnel set this to another address, because Cloudflare refuses uploads over 100 MB (see README). +#UPLOAD_URL=https://upload.example.com +# Shown when an upload can't connect at all, e.g. "Connect to the VPN to upload." +#UPLOAD_HINT=Can't reach the upload server. + +# How long clips live before Zipline deletes them (Zipline duration format: 30d, 90d, ...) +#CLIP_LIFETIME=90d +# CPU cap for intake (uploads, .mkv remuxing, Discord copies). Lower it if the machine runs hot. +#INTAKE_CPUS=4 +# How often Zipline makes library thumbnails for new uploads +#THUMBNAIL_INTERVAL=5m + +# Only for COMPOSE_PROFILES=tunnel: the token from Cloudflare Zero Trust -> Networks -> Tunnels +#TUNNEL_TOKEN= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fade080 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +data/ +__pycache__/ +.pytest_cache/ +.venv/ diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..b568efe --- /dev/null +++ b/Caddyfile @@ -0,0 +1,24 @@ +# The site, watch pages and uploads go to intake; everything else (the clips themselves, /dashboard, /api) to Zipline. +{$SITE_DOMAIN} { + @intake { + path / /v/* /_site/* + } + @upload { + method POST + path /upload + } + handle @intake { + reverse_proxy intake:8000 { + # intake trusts this header for the login rate limit; only Cloudflare may set it + header_up -Cf-Connecting-Ip + } + } + handle @upload { + reverse_proxy intake:8000 { + header_up -Cf-Connecting-Ip + } + } + handle { + reverse_proxy zipline:3000 + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9ad9584 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 esefxdz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..35f3eb5 --- /dev/null +++ b/README.md @@ -0,0 +1,223 @@ +

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/`: 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:///` 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:///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:///` 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/` 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:///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 `/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/`, 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). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..61f4ac5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,105 @@ +name: kaledonya-clips + +# Copy .env.example to .env, fill it in, then: docker compose up -d +# Data lives under ./data (database, clips, Caddy's certificates); intake's scratch space is the intake-work volume. + +services: + zipline-db: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_USER: zipline + POSTGRES_PASSWORD: ${POSTGRESQL_PASSWORD:?missing in .env} + POSTGRES_DB: zipline + volumes: + - ./data/pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD", "pg_isready", "-U", "zipline"] + interval: 10s + timeout: 5s + retries: 5 + + zipline: + image: ghcr.io/diced/zipline:4.8.0 + restart: unless-stopped + environment: + DATABASE_URL: postgres://zipline:${POSTGRESQL_PASSWORD}@zipline-db:5432/zipline + CORE_SECRET: ${CORE_SECRET:?missing in .env} + CORE_RETURN_HTTPS_URLS: "true" + CORE_DEFAULT_DOMAIN: ${SITE_DOMAIN:?missing in .env} + CORE_TRUST_PROXY: "true" + FILES_ROUTE: / # clips live at https:/// + FILES_DEFAULT_FORMAT: name + FILES_MAX_FILE_SIZE: 5gb + FILES_DEFAULT_EXPIRATION: ${CLIP_LIFETIME:-90d} + FEATURES_USER_REGISTRATION: "false" # invite only: the admin makes accounts in the dashboard + INVITES_ENABLED: "false" + TASKS_THUMBNAILS_INTERVAL: ${THUMBNAIL_INTERVAL:-5m} # library thumbnails for new uploads (Zipline default: 30m) + depends_on: + zipline-db: + condition: service_healthy + volumes: + - ./data/uploads:/zipline/uploads + - ./data/public:/zipline/public + - ./data/themes:/zipline/themes + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://0.0.0.0:3000/api/healthcheck"] + interval: 15s + timeout: 2s + retries: 2 + + intake: + build: ./intake + restart: unless-stopped + # uploads, .mkv remuxing and Discord copies; ffmpeg uses up to 4 threads at low priority + cpus: ${INTAKE_CPUS:-4} + environment: + ZIPLINE_URL: http://zipline:3000 + MAX_BYTES: "5368709120" + DELETES_AT: ${CLIP_LIFETIME:-90d} + SITE_ORIGIN: https://${SITE_DOMAIN} + UPLOAD_URL: ${UPLOAD_URL:-https://${SITE_DOMAIN}} + SITE_NAME: ${SITE_NAME:-kaledonya clips} + SERVER_NAME: ${SERVER_NAME:-the server} + UPLOAD_HINT: ${UPLOAD_HINT:-Can't reach the upload server.} + volumes: + - intake-work:/work # uploads in progress, .mkv remuxing, Discord copies (a named volume, so uid 1000 can write) + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"] + interval: 30s + timeout: 5s + retries: 3 + depends_on: + - zipline + + # Option A (default): Caddy on ports 80/443 with an automatic HTTPS certificate for SITE_DOMAIN. + caddy: + image: caddy:2 + restart: unless-stopped + profiles: ["caddy"] + ports: + - "80:80" + - "443:443" + environment: + SITE_DOMAIN: ${SITE_DOMAIN} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - ./data/caddy:/data + depends_on: + - intake + - zipline + + # Option B: a Cloudflare Tunnel instead of open ports (see README, "Cloudflare Tunnel"). + cloudflared: + image: cloudflare/cloudflared:2026.9.3 + restart: unless-stopped + profiles: ["tunnel"] + command: tunnel --no-autoupdate run + environment: + TUNNEL_TOKEN: ${TUNNEL_TOKEN:-} + depends_on: + - intake + - zipline + +volumes: + intake-work: diff --git a/docs/library.png b/docs/library.png new file mode 100644 index 0000000..7334fe4 Binary files /dev/null and b/docs/library.png differ diff --git a/docs/login.png b/docs/login.png new file mode 100644 index 0000000..7708f5d Binary files /dev/null and b/docs/login.png differ diff --git a/docs/upload-done.png b/docs/upload-done.png new file mode 100644 index 0000000..31ab172 Binary files /dev/null and b/docs/upload-done.png differ diff --git a/docs/upload.gif b/docs/upload.gif new file mode 100644 index 0000000..23b0c2b Binary files /dev/null and b/docs/upload.gif differ diff --git a/docs/watch.png b/docs/watch.png new file mode 100644 index 0000000..10d9eeb Binary files /dev/null and b/docs/watch.png differ diff --git a/intake/Dockerfile b/intake/Dockerfile new file mode 100644 index 0000000..1fb1d73 --- /dev/null +++ b/intake/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /srv +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY pytest.ini . +COPY app ./app +COPY site ./site +COPY tests ./tests +# run as an ordinary user (uid 1000); /work must be writable by it +RUN useradd --uid 1000 --no-create-home --shell /usr/sbin/nologin app && mkdir /work && chown app:app /work +USER app +ENV WORK_DIR=/work TMPDIR=/work +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/intake/app/__init__.py b/intake/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/intake/app/errors.py b/intake/app/errors.py new file mode 100644 index 0000000..7eb9b63 --- /dev/null +++ b/intake/app/errors.py @@ -0,0 +1,15 @@ +"""JSON error responses shared by /upload and the site API.""" +import httpx +from fastapi.responses import JSONResponse + + +def error(status: int, message: str) -> JSONResponse: + return JSONResponse({"error": message}, status_code=status) + + +def zipline_message(r: httpx.Response) -> str: + try: + body = r.json() + return str(body.get("error") or body.get("message") or body) + except ValueError: + return r.text[:200] or f"Zipline returned {r.status_code}" diff --git a/intake/app/main.py b/intake/app/main.py new file mode 100644 index 0000000..5f1f417 --- /dev/null +++ b/intake/app/main.py @@ -0,0 +1,3 @@ +from .server import create_app + +app = create_app() diff --git a/intake/app/media.py b/intake/app/media.py new file mode 100644 index 0000000..1d38c5b --- /dev/null +++ b/intake/app/media.py @@ -0,0 +1,38 @@ +"""Size and a still frame of a clip, read straight from Zipline with ffprobe/ffmpeg, for Discord's link preview.""" +import json +import subprocess +import threading +from functools import lru_cache + +# /v/ and /_site/thumb need no login: cap how many ffmpeg/ffprobe processes they can start at once +SLOTS = threading.BoundedSemaphore(2) + + +@lru_cache(maxsize=256) +def video_size(url: str, length: str) -> tuple[int, int] | None: + """(width, height) of the first video stream; `length` is only part of the cache key (a reused name).""" + cmd = ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=width,height", "-of", "json", url] + try: + with SLOTS: + result = subprocess.run(cmd, capture_output=True, timeout=10) + stream = json.loads(result.stdout)["streams"][0] + return int(stream["width"]), int(stream["height"]) + except (OSError, subprocess.TimeoutExpired, ValueError, KeyError, IndexError): + return None + + +@lru_cache(maxsize=64) +def still_jpeg(url: str, length: str) -> bytes | None: + """One frame (1 s in, or the first one for very short clips) as a JPEG at most 720 px tall.""" + for seek in ("1", "0"): + cmd = ["ffmpeg", "-v", "error", "-ss", seek, "-i", url, "-frames:v", "1", + "-vf", "scale=-2:'min(720,ih)'", "-f", "image2", "-c:v", "mjpeg", "pipe:1"] + try: + with SLOTS: + result = subprocess.run(cmd, capture_output=True, timeout=30) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode == 0 and result.stdout: + return result.stdout + return None diff --git a/intake/app/names.py b/intake/app/names.py new file mode 100644 index 0000000..1d22b84 --- /dev/null +++ b/intake/app/names.py @@ -0,0 +1,25 @@ +"""Filename rules for uploads: spaces become dashes, duplicates get -2, -3, ...""" +import os +import re +from collections.abc import Callable + + +def clean_name(filename: str) -> str: + """Keep only the base name and turn every run of whitespace into one dash.""" + base = filename.replace("\\", "/").rsplit("/", 1)[-1].strip() + base = re.sub(r"\s+", "-", base) + return base or "upload" + + +def split_name(name: str) -> tuple[str, str]: + """('clip', '.mp4'); a name with no extension gives ('README', '').""" + return os.path.splitext(name) + + +def next_free_name(stem: str, ext: str, exists: Callable[[str], bool], limit: int = 1000) -> str: + """First of stem+ext, stem-2+ext, stem-3+ext, ... that does not exist yet.""" + for n in range(1, limit + 1): + name = f"{stem}{ext}" if n == 1 else f"{stem}-{n}{ext}" + if not exists(name): + return name + raise RuntimeError(f"no free name for {stem}{ext} after {limit} tries") diff --git a/intake/app/previews.py b/intake/app/previews.py new file mode 100644 index 0000000..54ba41c --- /dev/null +++ b/intake/app/previews.py @@ -0,0 +1,216 @@ +"""Small Discord copies of big clips. + +Discord's crawler gives a linked video about 8 seconds to download and a home upload link of ~4 MB/s means a clip +over ~30 MB never gets a player. For those, the /v/ page points Discord at a 720p copy that fits in ~22 MB +(clips over 4 minutes: 480p in ~18 MB, so they download with room to spare); +the original stays untouched. Copies are made one at a time in the background and live in WORK_DIR/previews. +""" +import hashlib +import json +import logging +import queue +import re +import shutil +import subprocess +import threading +import time +from pathlib import Path +from urllib.parse import quote + +log = logging.getLogger("uvicorn.error") # uvicorn only prints its own loggers + +TARGET_BYTES = 22 * 1000 * 1000 +LONG_SECONDS = 240 # longer clips get a smaller, 480p copy +LONG_TARGET_BYTES = 18 * 1000 * 1000 +AUDIO_KBPS = 128 +MAX_VIDEO_KBPS = 4000 +MIN_VIDEO_KBPS = 250 # below this a clip is too long to be worth watching in Discord; it gets no copy +KEEP_DAYS = 95 # clips delete after 90 days +KEY_RE = re.compile(r"^[0-9a-f]{24}$") + + +def target_bytes(duration: float) -> int: + return LONG_TARGET_BYTES if duration > LONG_SECONDS else TARGET_BYTES + + +def video_kbps(duration: float) -> int | None: + """Video bitrate that makes `duration` seconds land near target_bytes(), or None when it can't sensibly fit.""" + if duration <= 0: + return None + kbps = min(MAX_VIDEO_KBPS, int(target_bytes(duration) * 8 / 1000 / duration) - AUDIO_KBPS) + return kbps if kbps >= MIN_VIDEO_KBPS else None + + +def encode_cmd(src: str, dst: Path, kbps: int, duration: float) -> list[str]: + height = 480 if duration > LONG_SECONDS else 720 + return [ + "ffmpeg", "-v", "error", "-y", "-threads", "4", "-i", src, + "-map", "0:v:0", "-map", "0:a:0?", # OBS clips carry a second audio track; Discord plays one + "-vf", f"scale=-2:'min({height},ih)'", "-fpsmax", "30", + "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p", + "-b:v", f"{kbps}k", "-maxrate", f"{kbps * 6 // 5}k", "-bufsize", f"{kbps * 2}k", + "-c:a", "aac", "-b:a", f"{AUDIO_KBPS}k", "-ac", "2", + "-movflags", "+faststart", "-progress", "pipe:1", "-nostats", str(dst), + ] + + +def follow_progress(lines, duration: float, report) -> None: + """Turn ffmpeg's `-progress` lines into fractions done (0..1) for the site's upload dialog.""" + for line in lines: + key, _, value = line.strip().partition("=") + if key == "out_time_us" and value.isdigit() and duration > 0: + report(min(1.0, round(int(value) / 1e6 / duration, 3))) + + +def duration_of(src: str) -> float: + cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json", src] + try: + out = subprocess.run(cmd, capture_output=True, timeout=30).stdout + return float(json.loads(out)["format"]["duration"]) + except (OSError, subprocess.TimeoutExpired, ValueError, KeyError): + return 0.0 + + +def niced(cmd: list[str]) -> list[str]: + """Run at low CPU priority where `nice` exists, so uploads and everything else on the box come first.""" + return ["nice", "-n", "10", *cmd] if shutil.which("nice") else cmd + + +class Previews: + def __init__(self, directory: Path, zipline_url: str, over_bytes: int): + self.dir = directory + self.zipline_url = zipline_url.rstrip("/") + self.over_bytes = over_bytes + self.jobs: queue.Queue[tuple[str, int]] = queue.Queue() + self.pending: set[str] = set() + self.lock = threading.Lock() + self.worker: threading.Thread | None = None + self.current: str | None = None # key being encoded right now, and how far along it is + self.progress = 0.0 + + @staticmethod + def key(name: str, length: int) -> str: + # the size is part of the key so a deleted clip's name reused for another video never gets the old copy + return hashlib.sha256(f"{name}\0{length}".encode()).hexdigest()[:24] + + def wanted(self, length: int) -> bool: + return length > self.over_bytes + + def file(self, key: str) -> Path | None: + path = self.dir / f"{key}.mp4" + return path if KEY_RE.match(key) and path.is_file() else None + + def ready(self, name: str, length: int) -> str | None: + """The key of a finished copy, or None (asking for one in the background if it's missing).""" + key = self.key(name, length) + if self.file(key): + return key + self.request(name, length) + return None + + def status(self, name: str, length: int) -> dict: + """For the site's upload dialog. state: "small" (plays as is), "ready" (copy made), "none" (too long, no + copy) or "making" (Discord would still get the big original), with how far the encode is and whether + it's still waiting behind another clip.""" + done = lambda state: {"state": state, "progress": 1.0, "queued": False} + if not self.wanted(length): + return done("small") + key = self.key(name, length) + if self.file(key): + return done("ready") + if (self.dir / f"{key}.failed").exists(): + return done("none") + self.request(name, length) + with self.lock: + now = self.current == key + return {"state": "making", "progress": self.progress if now else 0.0, "queued": not now} + + def renamed(self, old: str, new: str, length: int) -> None: + """Carry a clip's copy over to its new name; without it a renamed clip's link sends Discord the big original.""" + if not self.wanted(length): + return + old_key, new_key = self.key(old, length), self.key(new, length) + for ext in (".mp4", ".failed"): + try: + (self.dir / f"{old_key}{ext}").replace(self.dir / f"{new_key}{ext}") + except OSError: + pass + self.request(new, length) # still being made under the old name: start the new one now, not on the first view + + def request(self, name: str, length: int) -> None: + key = self.key(name, length) + if not self.wanted(length) or self.file(key) or (self.dir / f"{key}.failed").exists(): + return + with self.lock: + if key in self.pending: + return + self.pending.add(key) + self.jobs.put((name, length)) + if self.worker is None or not self.worker.is_alive(): + self.worker = threading.Thread(target=self.run, name="previews", daemon=True) + self.worker.start() + + def run(self) -> None: + while True: + try: + name, length = self.jobs.get(timeout=60) + except queue.Empty: + with self.lock: + if self.jobs.empty(): + self.worker = None + return + continue + key = self.key(name, length) + with self.lock: + self.current, self.progress = key, 0.0 + try: + self.prune() + self.make(name, key) + except Exception: # one bad clip must not kill the worker + log.exception("preview for %s failed", name) + finally: + with self.lock: + self.pending.discard(key) + self.current = None + + def make(self, name: str, key: str) -> None: + self.dir.mkdir(parents=True, exist_ok=True) + src = f"{self.zipline_url}/{quote(name)}" + duration = duration_of(src) + kbps = video_kbps(duration) + tmp, dst = self.dir / f"{key}.part.mp4", self.dir / f"{key}.mp4" + ok = False + if kbps: + started = time.monotonic() + try: + proc = subprocess.Popen(niced(encode_cmd(src, tmp, kbps, duration)), stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, text=True) + watchdog = threading.Timer(3 * 3600, proc.kill) + watchdog.start() + try: + follow_progress(proc.stdout, duration, self.set_progress) + code = proc.wait() + finally: + watchdog.cancel() + ok = code == 0 and tmp.is_file() and tmp.stat().st_size < TARGET_BYTES * 13 // 10 + except OSError: + ok = False + log.info("preview %s for %s: %s in %.0fs", key, name, "ok" if ok else "failed", time.monotonic() - started) + if ok: + tmp.replace(dst) + else: + tmp.unlink(missing_ok=True) + (self.dir / f"{key}.failed").touch() # don't burn CPU on it again + + def set_progress(self, fraction: float) -> None: + with self.lock: + self.progress = fraction + + def prune(self) -> None: + cutoff = time.time() - KEEP_DAYS * 86400 + for path in self.dir.glob("*"): + try: + if path.stat().st_mtime < cutoff: + path.unlink() + except OSError: + pass diff --git a/intake/app/remux.py b/intake/app/remux.py new file mode 100644 index 0000000..7942447 --- /dev/null +++ b/intake/app/remux.py @@ -0,0 +1,19 @@ +"""Stream-copy an .mkv into an .mp4 so Discord can play it inline (no re-encode).""" +import subprocess +from pathlib import Path + + +def remux_to_mp4(src: Path, dst: Path, timeout: int = 1800) -> bool: + cmd = [ + "ffmpeg", "-v", "error", "-y", "-i", str(src), + "-map", "0:v", "-map", "0:a?", "-c", "copy", + "-movflags", "+faststart", str(dst), + ] + try: + result = subprocess.run(cmd, capture_output=True, timeout=timeout) + ok = result.returncode == 0 and dst.exists() and dst.stat().st_size > 0 + except (OSError, subprocess.TimeoutExpired): + ok = False + if not ok: + dst.unlink(missing_ok=True) + return ok diff --git a/intake/app/server.py b/intake/app/server.py new file mode 100644 index 0000000..3af950b --- /dev/null +++ b/intake/app/server.py @@ -0,0 +1,178 @@ +"""intake: the upload door in front of Zipline (rename, remux .mkv, pick a free name, forward).""" +import asyncio +import http.cookiejar +import mimetypes +import os +import shutil +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from urllib.parse import quote + +import httpx +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles +from starlette.datastructures import UploadFile + +from .errors import error, zipline_message +from .names import clean_name, next_free_name, split_name +from .previews import Previews +from .remux import remux_to_mp4 +from .site_api import SITE_DIR, build_site_router +from .site_auth import LoginLimiter, TicketStore + +FORM_SLACK = 1024 * 1024 # room for the multipart headers around the file +UPLOAD_TRIES = 3 + + +@dataclass +class Settings: + zipline_url: str = "http://zipline:3000" + max_bytes: int = 5 * 1024**3 + deletes_at: str = "90d" + work_dir: Path = field(default_factory=lambda: Path(tempfile.gettempdir())) + site_origin: str = "http://localhost:3020" + upload_url: str = "http://localhost:3020" + site_name: str = "kaledonya clips" # its first word is the big logo word on the login screen + server_name: str = "the server" # shown as "Sending to " + upload_hint: str = "Can't reach the upload server." # shown when an upload can't connect at all + preview_over: int = 25 * 1000 * 1000 # bigger clips get a small copy for Discord (see previews.py) + + @classmethod + def from_env(cls) -> "Settings": + return cls( + zipline_url=os.environ.get("ZIPLINE_URL", cls.zipline_url), + max_bytes=int(os.environ.get("MAX_BYTES", cls.max_bytes)), + deletes_at=os.environ.get("DELETES_AT", cls.deletes_at), + work_dir=Path(os.environ.get("WORK_DIR", tempfile.gettempdir())), + site_origin=os.environ.get("SITE_ORIGIN", cls.site_origin), + upload_url=os.environ.get("UPLOAD_URL", cls.upload_url), + site_name=os.environ.get("SITE_NAME", cls.site_name), + server_name=os.environ.get("SERVER_NAME", cls.server_name), + upload_hint=os.environ.get("UPLOAD_HINT", cls.upload_hint), + preview_over=int(os.environ.get("PREVIEW_OVER", cls.preview_over)), + ) + + +def create_app(settings: Settings | None = None, transport: httpx.BaseTransport | None = None) -> FastAPI: + settings = settings or Settings.from_env() + app = FastAPI(title="zipline-intake") + # the site (SITE_ORIGIN) uploads straight to this service at UPLOAD_URL, which may be another hostname + app.add_middleware(CORSMiddleware, allow_origins=[settings.site_origin], + allow_methods=["POST"], allow_headers=["x-upload-ticket"]) + client = httpx.Client( + base_url=settings.zipline_url, + transport=transport, + timeout=httpx.Timeout(60.0, read=900.0, write=None), + ) + # never keep cookies: a Zipline login session must not ride along on anyone else's request + client.cookies.jar.set_policy(http.cookiejar.DefaultCookiePolicy(allowed_domains=[])) + too_big = lambda: error(413, f"file is larger than the {settings.max_bytes / 1024**3:g} GB limit") + + def exists(name: str) -> bool: + # 302: Zipline sends text and password-protected files to /view/, but the name is taken + return client.head("/" + quote(name)).status_code in (200, 302) + + tickets, limiter = TicketStore(), LoginLimiter() + previews = Previews(settings.work_dir / "previews", settings.zipline_url, settings.preview_over) + app.state.tickets, app.state.limiter, app.state.previews = tickets, limiter, previews + app.include_router(build_site_router(client, exists, tickets, limiter, settings, previews)) + app.mount("/_site/static", StaticFiles(directory=SITE_DIR), name="site-static") + + @app.middleware("http") + async def no_store_site_api(request: Request, call_next): + # Cloudflare caches anything ending in .mp4, even /_site/api/files/clip.mp4: never let it keep these + response = await call_next(request) + if request.url.path.startswith("/_site/api/"): + response.headers["cache-control"] = "no-store, private" + return response + + def forward(body, stem: str, ext: str, token: str, content_type: str, remuxed: bool) -> JSONResponse: + taken: set[str] = set() + for _ in range(UPLOAD_TRIES): + try: + name = next_free_name(stem, ext, lambda n: n in taken or exists(n)) + except httpx.HTTPError as e: + return error(502, f"could not reach Zipline: {e}") + except RuntimeError: + return error(409, "could not get a free filename, try again") + headers = { + "authorization": token, + "x-zipline-format": "name", + "x-zipline-filename": quote(name[: len(name) - len(ext)] if ext else name), + "x-zipline-original-name": "true", + "x-zipline-deletes-at": settings.deletes_at, + } + if ext: + headers["x-zipline-file-extension"] = ext + body.seek(0) + try: + r = client.post("/api/upload", headers=headers, files={"file": (name, body, content_type)}) + except httpx.HTTPError as e: + return error(502, f"could not reach Zipline: {e}") + if r.status_code == 200: + # build the link ourselves: a '#' or '?' left raw in Zipline's url would cut it off + url = settings.site_origin + "/" + quote(name) + if content_type.startswith("video/"): + previews.request(name, body.seek(0, os.SEEK_END)) + return JSONResponse({"url": url, "name": name, "remuxed": remuxed}) + if "already exists" in r.text: + taken.add(name) + continue + return error(r.status_code if r.status_code < 500 else 502, zipline_message(r)) + return error(409, "could not get a free filename, try again") + + def process(up: UploadFile, token: str) -> JSONResponse: + stem, ext = split_name(clean_name(up.filename or "upload")) + content_type = up.content_type or "application/octet-stream" + if content_type == "application/octet-stream": + # curl/ShareX don't say what the file is; Discord only plays video/mp4 etc. inline + content_type = mimetypes.guess_type(stem + ext)[0] or content_type + with tempfile.TemporaryDirectory(dir=settings.work_dir) as tmp: + body, remuxed = up.file, False + if ext.lower() == ".mkv": + src, dst = Path(tmp) / "in.mkv", Path(tmp) / "out.mp4" + up.file.seek(0) + with src.open("wb") as f: + shutil.copyfileobj(up.file, f, 1024 * 1024) + if remux_to_mp4(src, dst): + src.unlink() + body, ext, content_type, remuxed = dst.open("rb"), ".mp4", "video/mp4", True + try: + return forward(body, stem, ext, token, content_type, remuxed) + finally: + if remuxed: + body.close() + + @app.get("/health") + def health(): + return {"ok": True} + + @app.post("/upload") + async def upload(request: Request): + token = request.headers.get("authorization") + if not token: + ticket = request.headers.get("x-upload-ticket") + if ticket: + token = tickets.redeem(ticket) + if not token: + return error(401, "upload ticket expired, try again") + if not token: + return error(401, "missing Authorization header (your Zipline token)") + length = request.headers.get("content-length", "") + if length.isdigit() and int(length) > settings.max_bytes + FORM_SLACK: + return too_big() + form = await request.form(max_files=1, max_fields=10) + try: + up = form.get("file") + if not isinstance(up, UploadFile): + return error(400, "send the file in a form field named 'file'") + if up.size is not None and up.size > settings.max_bytes: + return too_big() + return await asyncio.to_thread(process, up, token) + finally: + await form.close() + + return app diff --git a/intake/app/site_api.py b/intake/app/site_api.py new file mode 100644 index 0000000..133ddf7 --- /dev/null +++ b/intake/app/site_api.py @@ -0,0 +1,296 @@ +"""The clips site: its pages plus a small API that talks to Zipline with the user's token from a cookie.""" +import colorsys +import hashlib +import html +import logging +import random +import re +from collections.abc import Callable +from pathlib import Path +from urllib.parse import quote + +import httpx +from fastapi import APIRouter, Request +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response +from pydantic import BaseModel + +from .errors import error, zipline_message +from .media import still_jpeg, video_size +from .names import clean_name +from .previews import Previews +from .site_auth import LoginLimiter, TicketStore + +log = logging.getLogger("uvicorn.error") +COOKIE = "ys" +COOKIE_AGE = 30 * 24 * 3600 +PER_PAGE = 24 +SITE_DIR = Path(__file__).resolve().parent.parent / "site" +SESSION_RE = re.compile(r"zipline_session=([^;]+)") + + +class Login(BaseModel): + username: str + password: str + + +class Rename(BaseModel): + name: str + + +def client_ip(request: Request) -> str: + # Cloudflare sets CF-Connecting-IP; over Tailscale, tailscale serve sets X-Forwarded-For + forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip() + return request.headers.get("cf-connecting-ip") or forwarded or (request.client.host if request.client else "?") + + +def file_view(f: dict) -> dict: + thumb = (f.get("thumbnail") or {}).get("path") + return { + "id": f["id"], "name": f["name"], "size": f.get("size", 0), "type": f.get("type", ""), + "views": f.get("views", 0), "createdAt": f.get("createdAt"), "deletesAt": f.get("deletesAt"), + "url": "/" + quote(f["name"]), "thumbnail": "/raw/" + quote(thumb) if thumb else None, + } + + +def preview_tags(name: str, origin: str, video: str, size: tuple[int, int] | None, site_name: str) -> str: + """Open Graph tags for /v/: Discord's player, a still and a title. `video` is the clip itself, + or for a big clip its small copy (Discord gives up on files it can't download in ~8 s).""" + tags = [("og:type", "video.other"), ("og:site_name", site_name), ("og:title", name), + ("og:image", f"{origin}/_site/thumb/{quote(name)}"), + ("og:video", video), ("og:video:url", video), + ("og:video:secure_url", video), ("og:video:type", "video/mp4")] + if size: + tags += [("og:video:width", str(size[0])), ("og:video:height", str(size[1]))] + meta = "".join(f'\n' for k, v in tags) + return meta + '\n' + + +def random_color() -> str: + """A bright color for the stripe Discord draws beside the embed (it reads the page's theme-color).""" + r, g, b = colorsys.hls_to_rgb(random.random(), 0.55, 0.75) + return "#%02x%02x%02x" % (round(r * 255), round(g * 255), round(b * 255)) + + +def site_page(site_name: str, server_name: str) -> str: + """index.html with {{v}} replaced by a hash of the assets, so browsers never keep a stale app.js/style.css, + and {{name}}/{{server}} by the configured names (app.js reads them before /config loads).""" + digest = hashlib.sha256() + for name in ("app.js", "style.css"): + digest.update((SITE_DIR / name).read_bytes()) + return ((SITE_DIR / "index.html").read_text(encoding="utf-8").replace("{{v}}", digest.hexdigest()[:10]) + .replace("{{name}}", html.escape(site_name)).replace("{{server}}", html.escape(server_name, quote=True))) + + +def build_site_router(client: httpx.Client, exists: Callable[[str], bool], tickets: TicketStore, + limiter: LoginLimiter, settings, previews: Previews) -> APIRouter: + r = APIRouter() + page = site_page(settings.site_name, settings.server_name) + + def logged_out() -> JSONResponse: + resp = error(401, "not logged in") + resp.delete_cookie(COOKIE, path="/", secure=True, httponly=True, samesite="lax") + return resp + + def call(method: str, path: str, token: str, **kw) -> httpx.Response | None: + try: + return client.request(method, path, headers={"authorization": token}, **kw) + except httpx.HTTPError: + return None + + def relay(resp: httpx.Response | None, ok: Callable[[dict], dict]): + if resp is None: + return error(502, "Zipline isn't reachable right now.") + if resp.status_code == 401: + return logged_out() + if resp.status_code == 404: + return error(404, "This clip doesn't exist (or isn't yours).") + if resp.status_code >= 400: + return error(resp.status_code if resp.status_code < 500 else 502, zipline_message(resp)) + return ok(resp.json()) + + def file_path(ident: str) -> str: + return "/api/user/files/" + quote(ident, safe="") + + def bad_ident(ident: str) -> bool: + # an empty or nested id would reach other Zipline routes (the list, /transaction, ...) + return not ident or "/" in ident + + no_clip = lambda: error(404, "This clip doesn't exist (or isn't yours).") + + @r.get("/", include_in_schema=False) + def index(): + return HTMLResponse(page, headers={"cache-control": "no-store"}) + + def video_head(name: str) -> httpx.Response | None: + """Zipline's answer for the raw clip when it's a video, else None.""" + try: + resp = client.head("/" + quote(name)) + except httpx.HTTPError: + return None + ok = resp.status_code == 200 and resp.headers.get("content-type", "").startswith("video/") + return resp if ok else None + + def raw_url(name: str) -> str: + return settings.zipline_url.rstrip("/") + "/" + quote(name) + + @r.get("/v/{name:path}", include_in_schema=False) + def watch(name: str, request: Request): + head = video_head(name) if name else None + if head is None: + return HTMLResponse(page, headers={"cache-control": "no-store"}) + length = head.headers.get("content-length", "") + key = previews.ready(name, int(length)) if length.isdigit() else None + if key: + video, size = f"{settings.site_origin}/_site/preview/{key}.mp4", video_size(str(previews.file(key)), key) + else: + video, size = f"{settings.site_origin}/{quote(name)}", video_size(raw_url(name), length) + log.info("/v/%s -> %s for %s", name, "copy" if key else "original", request.headers.get("user-agent", "?")) + tags = preview_tags(name, settings.site_origin, video, size, settings.site_name) + html_page = page.replace('', + f'', 1) + return HTMLResponse(html_page.replace("", tags + "", 1), headers={"cache-control": "no-store"}) + + @r.get("/_site/api/discord/{name:path}") + def discord_state(name: str): + # the upload dialog holds a clip's link back while this says "making": Discord caches whatever it sees first + head = video_head(name) if name else None + length = head.headers.get("content-length", "") if head is not None else "" + return previews.status(name, int(length) if length.isdigit() else 0) + + @r.get("/_site/thumb/{name:path}", include_in_schema=False) + def thumb(name: str): + head = video_head(name) if name else None + jpeg = head and still_jpeg(raw_url(name), head.headers.get("content-length", "")) + if not jpeg: + return error(404, "no preview for this clip") + return Response(jpeg, media_type="image/jpeg", headers={"cache-control": "public, max-age=3600"}) + + @r.get("/_site/preview/{key}.mp4", include_in_schema=False) + def preview(key: str): + path = previews.file(key) + if path is None: + return error(404, "no such preview") + return FileResponse(path, media_type="video/mp4") + + @r.get("/_site/api/config") + def config(): + return {"uploadUrl": settings.upload_url, "publicUrl": settings.site_origin, "uploadHint": settings.upload_hint} + + @r.get("/_site/api/public/{name:path}") + def public_file(name: str): + """What a visitor from a shared link may see: the raw file is public already, this only names it.""" + try: + head = client.head("/" + quote(name)) if name else None + except httpx.HTTPError: + return error(502, "Zipline isn't reachable right now.") + if head is None or head.status_code != 200: + return error(404, "This clip doesn't exist (anymore).") + kind = head.headers.get("content-type", "").split(";")[0] + length = head.headers.get("content-length", "0") + return {"name": name, "type": kind, "size": int(length) if length.isdigit() else 0, "url": "/" + quote(name), + "thumbnail": "/_site/thumb/" + quote(name) if kind.startswith("video/") else None} + + @r.post("/_site/api/login") + def login(body: Login, request: Request): + # strikes count per IP *and* account, so logging in as yourself doesn't clear guesses at someone else + ip = client_ip(request) + "|" + body.username.strip().lower() + if limiter.blocked(ip): + return error(429, "Too many tries, wait a few minutes.") + try: + r1 = client.post("/api/auth/login", json={"username": body.username, "password": body.password}) + if r1.status_code != 200: + if r1.status_code >= 500: + return error(502, "Zipline isn't reachable right now.") + limiter.fail(ip) + return error(401, "Wrong username or password.") + match = SESSION_RE.search(r1.headers.get("set-cookie", "")) + if not match: + return error(502, "Zipline didn't start a session.") + r2 = client.get("/api/user/token", headers={"cookie": f"zipline_session={match.group(1)}"}) + except httpx.HTTPError: + return error(502, "Zipline isn't reachable right now.") + finally: + client.cookies.clear() # never let one user's Zipline session ride along on someone else's request + if r2.status_code != 200: + return error(502, "Zipline didn't hand out a token.") + limiter.reset(ip) + resp = JSONResponse({"username": r1.json()["user"]["username"]}) + resp.set_cookie(COOKIE, r2.json()["token"], max_age=COOKIE_AGE, path="/", + httponly=True, secure=True, samesite="lax") + return resp + + @r.post("/_site/api/logout") + def logout(): + resp = JSONResponse({"ok": True}) + resp.delete_cookie(COOKIE, path="/", secure=True, httponly=True, samesite="lax") + return resp + + @r.get("/_site/api/me") + def me(request: Request): + token = request.cookies.get(COOKIE) + if not token: + return logged_out() + result = relay(call("GET", "/api/user", token), lambda d: {"username": d["user"]["username"]}) + if not isinstance(result, dict): + return result + stats = call("GET", "/api/user/stats", token) + ok = stats is not None and stats.status_code == 200 + return {**result, "storageUsed": stats.json().get("storageUsed", 0) if ok else 0} + + @r.get("/_site/api/files") + def files(request: Request, page: int = 1): + token = request.cookies.get(COOKIE) + if not token: + return logged_out() + params = {"page": max(page, 1), "perpage": PER_PAGE, "sortBy": "createdAt", "order": "desc"} + return relay(call("GET", "/api/user/files", token, params=params), lambda d: { + "files": [file_view(f) for f in d.get("page", [])], + "total": d.get("total", 0), "pages": d.get("pages", 1), + }) + + @r.get("/_site/api/files/{ident:path}") + def file(ident: str, request: Request): + token = request.cookies.get(COOKIE) + if not token: + return logged_out() + if bad_ident(ident): + return no_clip() + return relay(call("GET", file_path(ident), token), file_view) + + @r.patch("/_site/api/files/{ident:path}") + def rename(ident: str, body: Rename, request: Request): + token = request.cookies.get(COOKIE) + if not token: + return logged_out() + if bad_ident(ident): + return no_clip() + new = clean_name(body.name) + try: + if exists(new): + return error(409, "That name is already used.") + except httpx.HTTPError: + return error(502, "Zipline isn't reachable right now.") + before = call("GET", file_path(ident), token) + resp = call("PATCH", file_path(ident), token, json={"name": new}) + if before is not None and before.status_code == 200 and resp is not None and resp.status_code == 200: + was = before.json() + previews.renamed(was["name"], resp.json().get("name", new), int(was.get("size") or 0)) + return relay(resp, lambda d: {"name": d.get("name", new)}) + + @r.delete("/_site/api/files/{ident:path}") + def delete(ident: str, request: Request): + token = request.cookies.get(COOKIE) + if not token: + return logged_out() + if bad_ident(ident): + return no_clip() + return relay(call("DELETE", file_path(ident), token), lambda d: {"ok": True}) + + @r.post("/_site/api/upload-ticket") + def upload_ticket(request: Request): + token = request.cookies.get(COOKIE) + if not token: + return logged_out() + return {"ticket": tickets.issue(token), "uploadUrl": settings.upload_url} + + return r diff --git a/intake/app/site_auth.py b/intake/app/site_auth.py new file mode 100644 index 0000000..1907638 --- /dev/null +++ b/intake/app/site_auth.py @@ -0,0 +1,64 @@ +"""Login rate limit and one-time upload tickets for the clips site.""" +import secrets +import time +from collections import defaultdict, deque +from collections.abc import Callable + + +class LoginLimiter: + """At most `limit` failed logins per key (an IP) within `window` seconds.""" + + def __init__(self, limit: int = 5, window: float = 600, now: Callable[[], float] = time.monotonic): + self.limit, self.window, self.now = limit, window, now + self.fails: dict[str, deque] = defaultdict(deque) + self.swept = now() + + def _recent(self, key: str) -> deque: + q, cutoff = self.fails[key], self.now() - self.window + while q and q[0] <= cutoff: + q.popleft() + return q + + def blocked(self, key: str) -> bool: + if key not in self.fails: # don't grow the table for every key that's merely checked + return False + return len(self._recent(key)) >= self.limit + + def fail(self, key: str) -> None: + self._sweep() + self._recent(key).append(self.now()) + + def _sweep(self) -> None: + # forget keys whose strikes all expired, so guesses with made-up usernames can't grow the table forever + if self.now() - self.swept < self.window: + return + self.swept = self.now() + for key in [k for k in self.fails if not self._recent(k)]: + del self.fails[key] + + def reset(self, key: str) -> None: + self.fails.pop(key, None) + + +class TicketStore: + """One-time tickets that stand in for a Zipline token on /upload, valid `ttl` seconds.""" + + def __init__(self, ttl: float = 3600, now: Callable[[], float] = time.monotonic): + self.ttl, self.now = ttl, now + self.tickets: dict[str, tuple[str, float]] = {} + + def _prune(self) -> None: + now = self.now() + for ticket in [t for t, (_, expires) in self.tickets.items() if expires <= now]: + del self.tickets[ticket] + + def issue(self, token: str) -> str: + self._prune() + ticket = secrets.token_urlsafe(32) + self.tickets[ticket] = (token, self.now() + self.ttl) + return ticket + + def redeem(self, ticket: str) -> str | None: + self._prune() + entry = self.tickets.pop(ticket, None) + return entry[0] if entry else None diff --git a/intake/pytest.ini b/intake/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/intake/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/intake/requirements.txt b/intake/requirements.txt new file mode 100644 index 0000000..82608dc --- /dev/null +++ b/intake/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.142.2 +starlette==1.7.0 +pydantic==2.13.5 +uvicorn==0.54.0 +python-multipart==0.0.32 +httpx==0.28.1 diff --git a/intake/site/app.js b/intake/site/app.js new file mode 100644 index 0000000..7fc77a5 --- /dev/null +++ b/intake/site/app.js @@ -0,0 +1,526 @@ +'use strict'; +// Streamable-style library, watch page and uploads in a 2003 Steam skin. +// Talks to intake's /_site/api; uploads go straight to intake's UPLOAD_URL (cfg.uploadUrl). +const app = document.getElementById('app'); +const dialogs = document.getElementById('dialogs'); +const BLOCK = 12; // one 4px gap + one 8px block, like Steam's VGUI ProgressBar +let cfg = { uploadUrl: '', publicUrl: '', uploadHint: '' }; +// SITE_NAME and SERVER_NAME, filled into index.html by intake +const SITE = document.title, BRAND = SITE.split(' ')[0], SERVER = document.documentElement.dataset.server; +let me = null; +const state = { files: [], page: 0, pages: 1, total: null }; + +function h(tag, attrs = {}, ...kids) { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k.startsWith('on')) el.addEventListener(k.slice(2), v); + else if (k === 'class') el.className = v; + else if (k === 'style') el.style.cssText = v; + else if (k === 'value') el.value = v; + else if (v !== false && v != null) el.setAttribute(k, v === true ? '' : v); + } + for (const kid of kids.flat(Infinity)) if (kid != null && kid !== false) el.append(kid.nodeType ? kid : String(kid)); + return el; +} + +class LoggedOut extends Error {} +async function api(path, opts = {}) { + // no-store: answers differ per user, so never reuse one from the browser cache + const init = { method: opts.method || 'GET', credentials: 'same-origin', cache: 'no-store', headers: {} }; + if (opts.body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(opts.body); } + let r; + try { r = await fetch('/_site/api' + path, init); } catch { throw new Error(`Lost connection to ${SERVER}.`); } + let data = {}; + try { data = await r.json(); } catch {} + if (r.status === 401 && path !== '/login') throw new LoggedOut(); + if (!r.ok) throw new Error(data.error || 'Error ' + r.status); + return data; +} + +const fmtSize = (b) => { const u = ['B', 'KB', 'MB', 'GB']; let i = 0; while (b >= 1024 && i < 3) { b /= 1024; i++; } return (i ? b.toFixed(1) : b) + ' ' + u[i]; }; +const fmtDate = (s) => new Date(s).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }); +const daysLeft = (s) => (s ? Math.max(0, Math.ceil((new Date(s) - Date.now()) / 86400000)) : null); +const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`; +const media = (path) => cfg.publicUrl + path; +// the link to paste in Discord: /v/ gives Discord a player (and a small copy for big clips) +const isVideo = (name) => /\.(mp4|mov|m4v|webm)$/i.test(name); +const shareLink = (name, raw) => isVideo(name) ? cfg.publicUrl + '/v/' + encodeURIComponent(name) : raw; +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + +// the old Steam block bar: whole blocks only, added one at a time until they catch up with the real progress +function sbar() { + const bar = h('div', { class: 'sbar' }, h('div', { class: 'fill' })); + bar.shown = 0; bar.target = 0; bar.tick = null; + return bar; +} +function setBar(bar, frac) { + const total = Math.max(1, Math.floor(bar.clientWidth / BLOCK)); + bar.target = Math.floor(total * Math.min(1, Math.max(0, frac))); + if (bar.target < bar.shown) bar.shown = bar.target; // a new file starts again from empty + bar.firstChild.style.width = bar.shown * BLOCK + 'px'; + if (!bar.tick) bar.tick = setInterval(() => { + if (bar.shown < bar.target) bar.shown++; + else { clearInterval(bar.tick); bar.tick = null; } + bar.firstChild.style.width = bar.shown * BLOCK + 'px'; + }, 30); +} +const barFull = (bar) => new Promise((r) => { const t = setInterval(() => { if (!bar.tick) { clearInterval(t); r(); } }, 30); }); + +function dialog(title, body, { clear = false } = {}) { + const box = h('div', { class: 'dlg raised' }, h('div', { class: 'title' }, title), h('div', { class: 'body' }, body)); + const ov = h('div', { class: 'overlay' + (clear ? ' clear' : '') }, box); + dialogs.append(ov); + return { box, close: () => ov.remove() }; +} +function shake(box) { box.classList.remove('shake'); void box.offsetWidth; box.classList.add('shake'); } +function alertBox(title, msg) { + return new Promise((res) => { + const ok = h('button', { class: 'btn y', onclick: () => { d.close(); res(); } }, 'OK'); + const d = dialog(title, [h('div', { class: 'msg' }, msg), h('div', { class: 'row' }, ok)]); + ok.focus(); + }); +} +function confirmBox(title, msg, yes) { + return new Promise((res) => { + const d = dialog(title, [h('div', { class: 'msg' }, msg), h('div', { class: 'row' }, + h('button', { class: 'btn', onclick: () => { d.close(); res(false); } }, 'Cancel'), + h('button', { class: 'btn y', onclick: () => { d.close(); res(true); } }, yes))]); + }); +} +// check(value) resolves to an error message, or null to close the box +function promptBox(title, label, value, check) { + return new Promise((res) => { + const input = h('input', { class: 'field', value }); + const err = h('div', { class: 'err' }); + const go = async () => { + const e = await check(input.value.trim()); + if (e) { err.textContent = e; shake(d.box); } else { d.close(); res(true); } + }; + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') go(); + if (e.key === 'Escape') { d.close(); res(false); } + }); + const d = dialog(title, [h('label', {}, label), input, err, h('div', { class: 'row' }, + h('button', { class: 'btn', onclick: () => { d.close(); res(false); } }, 'Cancel'), + h('button', { class: 'btn y', onclick: go }, 'OK'))]); + input.focus(); input.select(); + }); +} +function toast(msg) { const t = h('div', { class: 'toast raised' }, msg); document.body.append(t); setTimeout(() => t.remove(), 1600); } +// Discord plays a big clip only from its small copy and caches whatever it sees first: on the clip page, a link +// whose copy is still being made shows "preparing for Discord... 64%" instead, with Copy off +async function holdForDiscord(name, el, show) { + if (!isVideo(name)) return show(true); + show(false, 'checking Discord...'); + for (let i = 0; i < 3600; i++) { + let s = { state: 'small' }; + try { s = await api('/discord/' + encodeURIComponent(name)); } catch {} + if (s.state !== 'making') return show(true, s.state === 'none' ? "Too long for Discord's player: the link opens the clip, but won't play in the chat." : ''); + show(false, 'preparing for Discord... ' + discordNote(s)); + await wait(1000); + if (!el.isConnected) return; // left the page + } + show(true); +} +async function copy(text) { + try { await navigator.clipboard.writeText(text); toast('Link copied'); } + catch { await promptBox('Copy link', 'Copy this link:', text, async () => null); } +} + +// static markup only (h() can't make SVG elements) +function svg(markup) { const t = document.createElement('template'); t.innerHTML = markup; return t.content.firstChild; } + +// full-screen login; the window buttons are only for the look +function loginDialog() { + return new Promise((res) => { + const user = h('input', { class: 'field', id: 'lu', autocomplete: 'username' }); + const pass = h('input', { class: 'field', id: 'lp', type: 'password', autocomplete: 'current-password' }); + const err = h('div', { class: 'err' }); + const bar = sbar(); + const conn = h('div', { style: 'display:none' }, h('div', {}, `Connecting to ${SERVER}...`), bar); + const form = h('form', { onsubmit: async (e) => { + e.preventDefault(); + err.textContent = ''; + form.style.display = 'none'; conn.style.display = ''; + bar.shown = 0; setBar(bar, 0.6); + try { + await api('/login', { method: 'POST', body: { username: user.value.trim(), password: pass.value } }); + setBar(bar, 1); await barFull(bar); + screen.remove(); res(); + } catch (x) { + conn.style.display = 'none'; form.style.display = ''; + err.textContent = x.message; shake(win); pass.select(); + } + } }, h('label', { for: 'lu' }, 'Account name'), user, h('label', { for: 'lp' }, 'Password'), pass, err, + h('div', { class: 'row' }, h('button', { class: 'btn y', type: 'submit' }, 'Log in'))); + const win = h('div', { class: 'win' }, + h('div', { class: 'title' }, h('i', {}, BRAND + ' · Login'), h('u', {}, '_'), h('u', {}, '□'), h('u', {}, '×')), + h('div', { class: 'body' }, + h('div', { class: 'brand' }, + svg(''), + h('strong', {}, SITE)), + form, conn)); + const screen = h('div', { class: 'login' }, + h('header', {}, h('b', {}, BRAND), h('div', { class: 'sp' }), + h('span', {}, `Connected to ${SERVER}`), h('span', { class: 'hide' }, 'Invite only'), h('span', {}, 'v1.0')), + h('div', { class: 'mark-word', 'aria-hidden': 'true' }, BRAND), + svg(''), + h('main', {}, win)); + dialogs.append(screen); + user.focus(); + }); +} + +function shell(content, sideOn) { + if (!me) { + // a visitor from a shared link: the clip, the look, and a way in for account holders + const top = h('div', { class: 'topbar' }, + h('a', { class: 'logo', href: '/' }, h('i'), SITE), + h('div', { class: 'user' }, h('button', { class: 'btn', onclick: async () => { await loginDialog(); boot(); } }, 'Log in'))); + app.replaceChildren(h('div', { class: 'shell' }, top, + h('div', { class: 'main' }, h('div', { class: 'content' }, h('div', { class: 'wrap' }, content))), + h('div', { class: 'statusbar' }, h('span', {}, 'connected · ' + SERVER), h('span', {}, 'guest')))); + return; + } + const side = h('div', { class: 'side' }, + h('div', { class: 'item' + (sideOn === 'all' ? ' on' : ''), onclick: () => go('/') }, + '▣ All videos', state.total != null ? ` (${state.total})` : ''), + h('div', { class: 'lbl' }, 'STORAGE'), + h('div', { class: 'small' }, fmtSize(me.storageUsed || 0) + ' used')); + const menu = h('div', { class: 'menu raised', style: 'display:none' }, h('div', { onclick: logout }, 'Log out')); + const top = h('div', { class: 'topbar' }, + h('button', { class: 'btn burger', onclick: () => side.classList.toggle('open') }, '☰'), + h('a', { class: 'logo', href: '/', onclick: (e) => { e.preventDefault(); go('/'); } }, h('i'), SITE), + h('div', { class: 'user' }, h('button', { class: 'btn', onclick: () => { menu.style.display = menu.style.display === 'none' ? '' : 'none'; } }, me.username + ' ▾'), menu)); + app.replaceChildren(h('div', { class: 'shell' }, top, + h('div', { class: 'main' }, side, h('div', { class: 'content' }, h('div', { class: 'wrap' }, content))), + h('div', { class: 'statusbar' }, h('span', {}, 'connected · ' + SERVER), h('span', {}, me.username)))); +} + +async function loadPage() { + const d = await api('/files?page=' + (state.page + 1)); + state.page++; state.pages = d.pages || 1; state.total = d.total; + state.files.push(...d.files); +} +function card(f) { + const left = daysLeft(f.deletesAt); + return h('div', { class: 'card raised', onclick: () => go('/v/' + encodeURIComponent(f.name)) }, + h('div', { class: 'thumb sunken', style: f.thumbnail ? `background-image:url("${media(f.thumbnail)}")` : '' }, f.thumbnail ? '' : '▶'), + h('div', { class: 't', title: f.name }, f.name), + h('div', { class: 'm' }, [fmtDate(f.createdAt), plural(f.views, 'view'), left != null ? left + 'd left' : null].filter(Boolean).join(' · '))); +} +function preload(files, progress) { + const urls = files.map((f) => f.thumbnail).filter(Boolean).slice(0, 12); + if (!urls.length) { progress(1); return Promise.resolve(); } + let n = 0; + const one = (u) => new Promise((r) => { + const img = new Image(); + img.onload = img.onerror = () => { progress(++n / urls.length); r(); }; + img.src = media(u); + }); + return Promise.race([Promise.all(urls.map(one)), wait(4000)]); +} +async function libraryView(progress) { + state.files = []; state.page = 0; + await loadPage(); + if (progress) await preload(state.files, progress); + renderLibrary(); +} +function renderLibrary() { + const picker = h('input', { type: 'file', multiple: true, style: 'display:none', onchange: () => { const f = [...picker.files]; picker.value = ''; uploadFiles(f); } }); + const more = state.page < state.pages ? h('div', { class: 'more' }, h('button', { class: 'btn', onclick: async (e) => { + e.target.disabled = true; + try { await loadPage(); renderLibrary(); } catch (x) { handleError(x); } + } }, 'Load more')) : null; + const body = state.files.length + ? [h('div', { class: 'grid' }, state.files.map(card)), more] + : h('div', { class: 'empty raised', onclick: () => picker.click() }, + h('div', { class: 'icon sunken' }, '⬆'), h('div', { class: 'big' }, 'Select videos to upload'), h('div', { class: 'dim' }, 'Or drag and drop video files')); + shell([picker, + h('div', { class: 'uploadbar raised' }, h('button', { class: 'btn y', onclick: () => picker.click() }, '⬆ Upload video'), + h('div', { class: 'hint sunken' }, 'or drop files anywhere · max 5 GB · .mkv becomes .mp4')), + h('h2', { class: 'count' }, 'All videos ', h('span', {}, `(${state.total ?? 0})`)), body], 'all'); +} + +async function watchView(name) { + let f = null; + if (me) { + try { f = await api('/files/' + encodeURIComponent(name)); } + catch (e) { if (e instanceof LoggedOut || !/doesn't exist/.test(e.message)) throw e; } + } + // not logged in, or someone else's clip: everyone may watch a shared link + if (!f) return publicWatchView(await api('/public/' + encodeURIComponent(name))); + if (state.total == null) { try { state.total = (await api('/files?page=1')).total; } catch {} } + const left = daysLeft(f.deletesAt); + const link = media(f.url); + const share = shareLink(f.name, link); + const copyBtn = h('button', { class: 'btn y', onclick: () => copy(share) }, '⧉ Copy link'); + const shown = h('div', { class: 'link sunken' }, share), note = h('div', { class: 'small' }); + const player = (f.type || '').startsWith('image/') + ? h('img', { class: 'sunken', src: link, alt: f.name }) + : h('video', { class: 'sunken', src: link, controls: true, preload: 'metadata', poster: f.thumbnail ? media(f.thumbnail) : false }); + shell([ + h('button', { class: 'btn back', onclick: () => go('/') }, '◂ All videos'), + h('div', { class: 'watch' }, player, + h('h1', {}, f.name), + h('div', { class: 'info' }, [fmtDate(f.createdAt), plural(f.views, 'view'), fmtSize(f.size), + left != null ? `deletes in ${plural(left, 'day')}` : null].filter(Boolean).join(' · ')), + h('div', { class: 'actions' }, + copyBtn, + h('a', { class: 'btn', href: link + '?download=true', download: f.name }, '⬇ Download'), + h('button', { class: 'btn', onclick: () => rename(f) }, '✎ Rename'), + h('button', { class: 'btn right', onclick: () => remove(f) }, '✖ Delete')), + shown, note)], null); + holdForDiscord(f.name, shown, (ready, msg = '') => { + copyBtn.disabled = !ready; shown.textContent = ready ? share : msg; note.textContent = ready ? msg : ''; + }); +} +function publicWatchView(f) { + const link = media(f.url); + const share = shareLink(f.name, link); + const player = (f.type || '').startsWith('image/') + ? h('img', { class: 'sunken', src: link, alt: f.name }) + : h('video', { class: 'sunken', src: link, controls: true, preload: 'metadata', poster: f.thumbnail ? media(f.thumbnail) : false }); + shell([ + me ? h('button', { class: 'btn back', onclick: () => go('/') }, '◂ All videos') : null, + h('div', { class: 'watch' }, player, + h('h1', {}, f.name), + h('div', { class: 'info' }, fmtSize(f.size)), + h('div', { class: 'actions' }, + h('button', { class: 'btn y', onclick: () => copy(share) }, '⧉ Copy link'), + h('a', { class: 'btn', href: link + '?download=true', download: f.name }, '⬇ Download')), + h('div', { class: 'link sunken' }, share))], null); +} +async function rename(f) { + const ext = (f.name.match(/\.[^.]+$/) || [''])[0]; + let renamed = null; + const ok = await promptBox('Rename', 'New name', f.name, async (v) => { + if (!v) return 'Type a name.'; + if (ext && !v.toLowerCase().endsWith(ext.toLowerCase())) v += ext; + if (v === f.name) return null; + try { renamed = (await api('/files/' + encodeURIComponent(f.id), { method: 'PATCH', body: { name: v } })).name; return null; } + catch (e) { if (e instanceof LoggedOut) { location.reload(); return null; } return e.message; } + }); + if (ok && renamed) go('/v/' + encodeURIComponent(renamed), true); +} +async function remove(f) { + if (!(await confirmBox('Delete clip', `Delete ${f.name}? The link stops working.`, 'Delete'))) return; + try { await api('/files/' + encodeURIComponent(f.id), { method: 'DELETE' }); toast('Deleted'); go('/'); } + catch (e) { handleError(e); } +} + +function send(file, ticket, onProgress, onSent) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const form = new FormData(); + form.append('file', file, file.name); + const t0 = Date.now(); + let sentAny = false; + xhr.open('POST', ticket.uploadUrl + '/upload'); + xhr.setRequestHeader('x-upload-ticket', ticket.ticket); + xhr.upload.onprogress = (e) => { + if (!e.lengthComputable) return; + sentAny = sentAny || e.loaded > 0; + const rate = e.loaded / Math.max((Date.now() - t0) / 1000, 0.001); + const left = rate > 0 ? Math.round((e.total - e.loaded) / rate) : 0; + onProgress(xhr, e.loaded / e.total, `Sending to ${SERVER} · ${fmtSize(e.loaded)} / ${fmtSize(e.total)} · ${fmtSize(rate)}/s · ${left}s left`); + }; + xhr.upload.onload = onSent; + xhr.onload = () => { + let data = {}; + try { data = JSON.parse(xhr.responseText); } catch {} + if (xhr.status === 200) resolve(data); else reject(new Error(data.error || `Upload failed (${xhr.status})`)); + }; + xhr.onerror = () => reject(new Error(sentAny ? `Lost connection to ${SERVER}.` : cfg.uploadHint)); + xhr.onabort = () => reject(new Error('Cancelled')); + onProgress(xhr, 0, `Connecting to ${SERVER}...`); + xhr.send(form); + }); +} +// what /discord says about a clip, worded for the dialog +function discordNote(s) { + if (s.state === 'small') return 'plays as is'; + if (s.state === 'ready') return 'ready'; + if (s.state === 'none') return "too long for Discord's player"; + if (s.queued) return 'waiting for another clip...'; + return s.progress >= 0.99 ? 'finishing...' : Math.round(s.progress * 100) + '%'; +} +const fmtLeft = (s) => s < 60 ? `~${Math.max(1, Math.round(s))} s` : `~${Math.round(s / 60)} min`; +const linkName = (r) => r.name + (r.remuxed ? ' (converted to .mp4)' + : /\.mkv$/i.test(r.name) ? " (couldn't convert to .mp4, kept as .mkv: Discord may not play it inline)" : ''); + +// the upload dialog walks through three steps, like Steam installing a game: +// send each file to the server, wait for the small Discord copies, then hand out the links. +// Discord caches whatever it sees first, so a link is only shown once its clip will play there. +async function uploadFiles(files) { + if (!files.length) return; + const bar = sbar(), line = h('div', { class: 'small' }), sub = h('div', { class: 'small' }), extra = h('div'); + const steps = ['Send to ' + SERVER, 'Prepare for Discord', 'Link ready'].map((label) => { + const mark = h('i', {}, '·'), right = h('span', { class: 'r' }); + const row = h('div', {}, mark, h('span', {}, label), right); + row.set = (state, text = '') => { row.className = state; mark.textContent = { done: '✓', now: '▸' }[state] || '·'; right.textContent = text; }; + return row; + }); + let xhr = null, cancelled = false, failure = null; + const btn = h('button', { class: 'btn', onclick: () => { cancelled = true; if (xhr) xhr.abort(); } }, 'Cancel'); + const d = dialog('Uploading', [h('div', { class: 'steps' }, steps), line, bar, sub, extra, h('div', { class: 'row' }, btn)]); + d.box.classList.add('install'); + const title = d.box.querySelector('.title'); + const done = []; + + // step 1: send the files, one at a time (keeps the server's CPU and upload link calm) + for (let i = 0; i < files.length && !cancelled; i++) { + const file = files[i]; + title.textContent = 'Uploading · ' + file.name; + steps[0].set('now', files.length > 1 ? `${i + 1} of ${files.length}` : fmtSize(file.size)); + setBar(bar, 0); + sub.textContent = /\.mkv$/i.test(file.name) ? 'then: converting .mkv to .mp4' : ''; + btn.disabled = false; + try { + const ticket = await api('/upload-ticket', { method: 'POST' }); + done.push(await send(file, ticket, + (x, frac, text) => { xhr = x; setBar(bar, frac); line.textContent = text; }, + () => { setBar(bar, 1); line.textContent = `Processing on ${SERVER}...`; btn.disabled = true; })); + } catch (e) { + if (!cancelled) failure = `${file.name}: ${e instanceof LoggedOut ? 'You were logged out. Log in again.' : e.message}`; + break; + } + } + if (!done.length) { + d.close(); + if (failure) await alertBox('Upload failed', failure); + return go(location.pathname, true); + } + steps[0].set('done', done.length > 1 ? plural(done.length, 'clip') : fmtSize(files[0].size)); + + // step 2: wait until Discord can play every clip (closing early is fine: the copies get made anyway) + await barFull(bar); + title.textContent = 'Preparing for Discord'; + steps[1].set('now'); + sub.textContent = ''; + setBar(bar, 0); + line.textContent = 'Discord only waits ~8 s for a video, so big clips get a small 720p copy just for it. Your original stays full quality.'; + let closed = false; + btn.textContent = 'Close'; btn.disabled = false; + btn.onclick = () => { closed = true; d.close(); }; + const perClip = done.map((r) => h('div', { class: 'clip' }, h('span', {}, r.name), h('span', { class: 'r' }))); + if (done.length > 1) extra.replaceChildren(h('div', { class: 'clips sunken' }, perClip)); + const status = done.map(() => ({ state: 'making', progress: 0, queued: true })); + let first = null; + for (;;) { + await Promise.all(done.map(async (r, i) => { + if (status[i].state !== 'making') return; + if (!isVideo(r.name)) { status[i] = { state: 'small', progress: 1, queued: false }; return; } + try { status[i] = await api('/discord/' + encodeURIComponent(r.name)); } + catch { status[i] = { state: 'small', progress: 1, queued: false }; } // can't ask: don't hold the link forever + })); + if (closed) return go(location.pathname, true); + const making = status.filter((s) => s.state === 'making'); + const frac = status.reduce((a, s) => a + (s.state === 'making' ? s.progress : 1), 0) / status.length; + status.forEach((s, i) => { perClip[i].lastChild.textContent = discordNote(s); }); + setBar(bar, frac); + if (!making.length) break; + if (making.every((s) => s.queued)) steps[1].set('now', 'waiting for another clip...'); + else { + if (!first && frac > 0) first = { t: Date.now(), frac }; + const rate = first && frac > first.frac ? (frac - first.frac) / (Date.now() - first.t) : 0; + steps[1].set('now', Math.round(frac * 100) + '%' + (rate ? ' · ' + fmtLeft((1 - frac) / rate / 1000) : '')); + } + await wait(1000); + if (closed) return go(location.pathname, true); + } + const allSmall = status.every((s) => s.state === 'small'); + steps[1].set('done', allSmall ? 'plays as is' : '100%'); + await barFull(bar); + + // step 3: the links, Copy lit up + steps[2].set('done'); + title.textContent = done.length > 1 ? `Ready · ${plural(done.length, 'clip')}` : 'Ready · ' + done[0].name; + line.textContent = done.length > 1 ? 'Paste these in Discord: they play right in the chat.' : 'Paste this in Discord: it plays right in the chat.'; + bar.remove(); + extra.replaceChildren(...done.map((r, i) => { + const share = shareLink(r.name, r.url); + return h('div', { class: 'linkrow' }, + done.length > 1 || r.remuxed || /\.mkv$/i.test(r.name) ? h('div', { class: 'small' }, linkName(r)) : null, + h('div', { style: 'display:flex;gap:6px' }, + h('input', { class: 'field', value: share, readonly: true, onclick: (e) => e.target.select() }), + h('button', { class: 'btn y', onclick: () => copy(share) }, 'Copy')), + status[i].state === 'none' ? h('div', { class: 'small' }, "Too long for Discord's player: the link opens the clip, but won't play in the chat.") : null); + })); + if (status.some((s) => s.state === 'none') && done.length === 1) line.textContent = 'Your link:'; + d.box.classList.add('ready'); + await new Promise((res) => { btn.onclick = () => { d.close(); res(); }; }); + if (failure) await alertBox('Upload failed', failure); + go(location.pathname, true); +} + +function go(path, replace = false) { + if (path !== location.pathname) history[replace ? 'replaceState' : 'pushState']({}, '', path); + route().catch(handleError); +} +async function route(progress) { + const m = location.pathname.match(/^\/v\/(.+)$/); + if (m) { + let name; + try { name = decodeURIComponent(m[1]); } catch { throw new Error("This clip doesn't exist (or isn't yours)."); } + await watchView(name); + if (progress) progress(1); + } + else await libraryView(progress); +} +async function handleError(e) { + if (e instanceof LoggedOut) { + await loginDialog(); + me = await api('/me'); + return route().catch(handleError); + } + await alertBox('Error', e.message); + if (location.pathname !== '/') go('/', true); +} +async function logout() { await api('/logout', { method: 'POST' }).catch(() => {}); location.reload(); } + +// the loading dialog moves on real steps: config+session, library list, thumbnails +async function boot() { + const bar = sbar(), status = h('div', { class: 'small' }, `Connecting to ${SERVER}...`); + const d = dialog(SITE, [h('div', { class: 'small' }, 'Loading...'), bar, status], { clear: true }); + const step = (frac, text) => { setBar(bar, frac); status.textContent = text; }; + try { + step(0.1, 'Checking your session...'); + cfg = await api('/config'); + try { me = await api('/me'); } + catch (e) { if (!(e instanceof LoggedOut) || !location.pathname.startsWith('/v/')) throw e; me = null; } + step(0.4, 'Loading your videos...'); + await route((frac) => step(0.5 + frac * 0.5, 'Loading thumbnails...')); + step(1, 'Done'); + await barFull(bar); + await wait(250); + d.close(); + } catch (e) { + d.close(); + if (e instanceof LoggedOut) { await loginDialog(); return boot(); } + if (location.pathname !== '/') { + // a clip that's gone (or someone else's): say so, then show the library + await alertBox(SITE, e.message); + history.replaceState({}, '', '/'); + return boot(); + } + dialog(SITE, [h('div', { class: 'msg' }, e.message), + h('div', { class: 'row' }, h('button', { class: 'btn y', onclick: () => location.reload() }, 'Retry'))], { clear: true }); + } +} + +let dragDepth = 0; +const hasFiles = (e) => e.dataTransfer && [...e.dataTransfer.types].includes('Files'); +document.addEventListener('dragenter', (e) => { if (!me || !hasFiles(e)) return; e.preventDefault(); dragDepth++; document.body.classList.add('dropping'); }); +document.addEventListener('dragleave', () => { if (--dragDepth <= 0) { dragDepth = 0; document.body.classList.remove('dropping'); } }); +document.addEventListener('dragover', (e) => { if (hasFiles(e)) e.preventDefault(); }); +document.addEventListener('drop', (e) => { + if (!hasFiles(e)) return; + e.preventDefault(); dragDepth = 0; document.body.classList.remove('dropping'); + if (me && e.dataTransfer.files.length) uploadFiles([...e.dataTransfer.files]); +}); +document.addEventListener('click', (e) => { + document.querySelectorAll('.menu').forEach((m) => { if (!m.parentElement.contains(e.target)) m.style.display = 'none'; }); +}); +window.addEventListener('popstate', () => route().catch(handleError)); +boot(); diff --git a/intake/site/index.html b/intake/site/index.html new file mode 100644 index 0000000..7864db3 --- /dev/null +++ b/intake/site/index.html @@ -0,0 +1,15 @@ + + + + + + +{{name}} + + + + +
+
+ + diff --git a/intake/site/style.css b/intake/site/style.css new file mode 100644 index 0000000..d05e02c --- /dev/null +++ b/intake/site/style.css @@ -0,0 +1,135 @@ +/* clips site: 2003 Steam / gamedate.org skin on a Streamable layout */ +:root{--body-bg:#3e4637;--light:#4c5844;--lighter:#5a6a50;--bl:#899281;--bd:#292d23;--text:#d8ded3;--dim:#b8c4ad; + --yel:#c4b550;--link:#b8a040;--sel:#96892d;--well:#2f3529} +*{box-sizing:border-box;margin:0;padding:0} +html,body,#app{height:100%} +body{background:var(--body-bg);color:var(--text);font:13px "Trebuchet MS",Verdana,sans-serif} +::selection{background:var(--sel);color:#fff} +a{color:var(--link)} +button,input{font:inherit;color:inherit} +.raised{border:1px solid;border-color:var(--bl) var(--bd) var(--bd) var(--bl)} +.sunken{border:1px solid;border-color:var(--bd) var(--bl) var(--bl) var(--bd)} +.btn{background:var(--light);color:var(--text);padding:3px 12px;cursor:pointer;border:1px solid;border-color:var(--bl) var(--bd) var(--bd) var(--bl);text-decoration:none;display:inline-block} +.btn:hover{background:var(--lighter)} +.btn:active{border-color:var(--bd) var(--bl) var(--bl) var(--bd)} +.btn.y{background:var(--yel);color:#2b2a10;font-weight:bold;border-color:#e6da8a #6f6620 #6f6620 #e6da8a} +.btn.y:hover{background:#d2c35c} +.btn:disabled{opacity:.5;cursor:default} +input.field{background:var(--well);border:1px solid;border-color:var(--bd) var(--bl) var(--bl) var(--bd);padding:4px 6px;width:100%} +input.field:focus{outline:1px solid var(--yel)} + +.shell{display:flex;flex-direction:column;height:100%} +.topbar{display:flex;align-items:center;gap:8px;padding:4px 8px;background:var(--light);border-bottom:1px solid var(--bd)} +.logo{display:flex;align-items:center;gap:7px;color:#fff;font-weight:bold;font-size:15px;text-decoration:none} +.logo i{width:16px;height:16px;background:var(--yel);border:1px solid;border-color:#e6da8a #6f6620 #6f6620 #e6da8a} +.user{margin-left:auto;position:relative} +.menu{position:absolute;right:0;top:100%;margin-top:2px;background:var(--light);min-width:140px;z-index:20} +.menu div{padding:4px 10px;cursor:pointer} +.menu div:hover{background:var(--sel);color:#fff} +.burger{display:none} +.main{display:flex;flex:1;min-height:0} +.side{width:200px;flex:none;background:var(--light);border-right:1px solid var(--bd);padding:8px} +.side .item{padding:4px 8px;cursor:pointer} +.side .item.on{background:var(--lighter);color:var(--yel)} +.side .lbl{margin-top:16px;font-size:10px;letter-spacing:1px;color:var(--dim)} +.side .small{font-size:11px;color:var(--dim);margin-top:2px} +.content{flex:1;overflow:auto;padding:12px 16px} +.wrap{max-width:1100px;margin:0 auto} +.wrap:has(.grid){max-width:1500px} /* the library gets more width than the watch page */ +.uploadbar{display:flex;gap:8px;align-items:center;background:var(--light);padding:8px} +.uploadbar .hint{flex:1;background:var(--well);color:var(--dim);padding:4px 8px} +h2.count{color:var(--yel);font-size:15px;margin:14px 0 8px;font-weight:normal} +h2.count span{color:var(--dim)} +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px} /* 4 across on a laptop, 5 on 1080p */ +.card{background:var(--light);padding:5px;cursor:pointer} +.card:hover{background:var(--lighter)} +.thumb{aspect-ratio:16/9;background:var(--well) center/cover no-repeat;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:22px} +.card .t{margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.card .m{font-size:11px;color:var(--dim)} +.more{text-align:center;margin:14px 0} +.empty{background:var(--light);min-height:320px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;cursor:pointer} +.empty .icon{width:72px;height:72px;background:var(--well);display:flex;align-items:center;justify-content:center;font-size:32px;color:var(--yel)} +.empty .big{font-size:16px} +.empty .dim{color:var(--dim)} +.dropping .content{outline:2px dashed var(--yel);outline-offset:-6px} +.back{margin-bottom:10px} +.watch video,.watch img{width:100%;max-height:70vh;background:#000;display:block;object-fit:contain} +.watch h1{color:var(--yel);font-size:18px;font-weight:normal;margin-top:10px;word-break:break-all} +.watch .info{color:var(--dim);font-size:12px;margin-top:2px} +.watch .actions{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap} +.watch .actions .right{margin-left:auto} +.watch .link{margin-top:8px;background:var(--well);color:var(--dim);padding:4px 8px;font-size:12px;word-break:break-all} +.statusbar{display:flex;justify-content:space-between;background:var(--light);border-top:1px solid var(--bd);font-size:10px;color:var(--dim);padding:2px 8px} + +.overlay{position:fixed;inset:0;background:#0006;display:flex;align-items:center;justify-content:center;z-index:50;padding:16px} +.overlay.clear{background:var(--body-bg)} +.dlg{background:var(--light);width:360px;max-width:100%} +.dlg .title{background:var(--lighter);color:#fff;font-size:11px;font-weight:bold;letter-spacing:1px;padding:5px 8px;border-bottom:1px solid var(--bd);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.dlg .body{padding:12px} +.dlg .row{display:flex;gap:6px;justify-content:flex-end;margin-top:12px} +.dlg label{display:block;font-size:11px;color:var(--dim);margin:8px 0 3px} +.dlg .msg{font-size:12px;line-height:1.5;word-break:break-word} +.dlg .err{color:#e8b7a0;font-size:12px;margin-top:8px;min-height:1em} +.dlg .small{font-size:10px;color:var(--dim)} +/* Steam 2004 progress bar (VGUI ProgressBar): 8px maize blocks, 4px gaps, 4px inset, lit-from-below well */ +.sbar{height:24px;padding:4px 0;margin:6px 0;overflow:hidden;background:var(--body-bg); + border:1px solid;border-color:#282e22 #808080 #808080 #282e22} +.sbar .fill{height:100%;width:0;background:repeating-linear-gradient(90deg,transparent 0 4px,var(--yel) 4px 12px)} +/* upload dialog as a 3-step install: done steps ticked, the current one lit yellow */ +.dlg.install{width:420px} +.steps{margin:0 0 10px;font-size:12px} +.steps div{display:flex;gap:8px;align-items:baseline;padding:3px 0;color:#8a947f} +.steps div i{font-style:normal;width:14px;text-align:center} +.steps div .r{margin-left:auto;font-size:11px} +.steps div.done{color:var(--text)} +.steps div.done i{color:var(--yel)} +.steps div.now{color:var(--yel);font-weight:bold} +.steps div.now .r{font-weight:normal} +.install .clips{background:var(--well);margin-top:8px;padding:4px 8px;font-size:11px;max-height:120px;overflow:auto} +.install .clip{display:flex;gap:8px;color:var(--dim);padding:1px 0} +.install .clip span:first-child{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.install .linkrow{margin-top:8px} +.install.ready .steps{border-bottom:1px solid var(--bd);box-shadow:0 1px 0 var(--bl);padding-bottom:8px} +.install .row{margin-top:10px} +@keyframes shake{0%,100%{transform:translateX(0)}20%,60%{transform:translateX(-6px)}40%,80%{transform:translateX(6px)}} +.shake{animation:shake .35s} +/* login screen */ +.login{--bg:#4c5844;--panel:#3e4637;--hi:#889180;--lo:#2b3125;--dim:#a4ad9b;--mark:#445040; + position:fixed;inset:0;z-index:50;display:flex;flex-direction:column;overflow:hidden; + background:var(--bg) radial-gradient(rgba(0,0,0,.12) 1px,transparent 1px) 0 0/3px 3px;font:12px/1.4 Tahoma,Verdana,sans-serif} +@media (prefers-color-scheme:dark){.login{--bg:#434e3c;--mark:#3b4636}} +.login header{background:var(--panel);display:flex;align-items:center;gap:6px;padding:4px 10px;border-bottom:1px solid var(--lo);box-shadow:0 1px 0 var(--hi);position:relative;z-index:2} +.login header b{font-size:13px;letter-spacing:.5px} +.login header span{color:var(--dim);padding:1px 8px} +.login .sp{flex:1} +.login .mark{position:absolute;right:-90px;bottom:-60px;width:620px;max-width:90vw;fill:var(--mark);pointer-events:none} +.login .mark path{fill:var(--bg)} +.login .mark-word{position:absolute;left:36px;top:70px;font:bold clamp(48px,9vw,120px)/1 Tahoma,Verdana,sans-serif;color:var(--mark);letter-spacing:-2px;pointer-events:none;user-select:none} +.login main{flex:1;display:flex;align-items:center;justify-content:center;padding:20px;position:relative} +.login .win{width:340px;max-width:100%;background:var(--panel);position:relative;z-index:2;border:1px solid;border-color:var(--hi) var(--lo) var(--lo) var(--hi)} +.login .title{background:var(--lo);display:flex;align-items:center;padding:3px 4px 3px 8px;font-weight:bold} +.login .title i{flex:1;font-style:normal} +.login .title u{width:16px;height:14px;margin-left:2px;background:var(--panel);text-decoration:none;text-align:center;font:bold 10px/12px Tahoma} +.login .body{padding:14px 16px 16px} +.login .brand{display:flex;align-items:center;gap:10px;margin-bottom:14px} +.login .brand svg{width:34px;height:34px;flex:none} +.login .brand strong{font-size:22px;letter-spacing:-.5px} +.login label{display:block;margin:10px 0 3px;color:var(--dim)} +.login input.field{background:var(--lo);font:13px Tahoma,Verdana,sans-serif;padding:5px 6px;border-color:var(--lo) var(--hi) var(--hi) var(--lo)} +.login input.field:focus{outline:1px dotted var(--yel);outline-offset:1px} +.login .err{color:var(--yel);margin-top:10px;min-height:16px} +.login .row{display:flex;justify-content:flex-end;margin-top:14px} +.login .btn.y{font:bold 12px Tahoma,Verdana,sans-serif;padding:5px 22px} +.login .sbar{margin-top:10px} +@media (max-width:560px){.login .hide{display:none}.login .mark-word{top:50px}} +.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--light);padding:6px 14px;z-index:60} + +@media (max-width:760px){ + .burger{display:inline-block} + .side{position:fixed;top:33px;bottom:0;left:0;z-index:30;display:none} + .side.open{display:block} + .grid{grid-template-columns:repeat(2,1fr)} + .content{padding:8px} + .uploadbar .hint{display:none} +} +@media (max-width:440px){.grid{grid-template-columns:1fr}} diff --git a/intake/tests/test_names.py b/intake/tests/test_names.py new file mode 100644 index 0000000..20823cc --- /dev/null +++ b/intake/tests/test_names.py @@ -0,0 +1,48 @@ +import pytest + +from app.names import clean_name, next_free_name, split_name + + +@pytest.mark.parametrize( + "raw, expected", + [ + ("2026-10-01 21-33-07.mkv", "2026-10-01-21-33-07.mkv"), + ("my clip\tfinal.mp4", "my-clip-final.mp4"), + (" padded.mp4 ", "padded.mp4"), + ("C:\\Users\\alice\\Videos\\a b.mp4", "a-b.mp4"), + ("dir/sub/x y.png", "x-y.png"), + ("çılgın an #1.mp4", "çılgın-an-#1.mp4"), + ("", "upload"), + (" ", "upload"), + ], +) +def test_clean_name(raw, expected): + assert clean_name(raw) == expected + + +def test_split_name(): + assert split_name("clip.mp4") == ("clip", ".mp4") + assert split_name("a.b.mkv") == ("a.b", ".mkv") + + +def test_split_name_edge_cases(): + assert split_name("README") == ("README", "") + assert split_name(".mp4") == (".mp4", "") + + +def test_next_free_name_free(): + assert next_free_name("clip", ".mp4", lambda n: False) == "clip.mp4" + + +def test_next_free_name_suffixes(): + taken = {"clip.mp4", "clip-2.mp4"} + assert next_free_name("clip", ".mp4", taken.__contains__) == "clip-3.mp4" + + +def test_next_free_name_no_extension(): + assert next_free_name("README", "", {"README"}.__contains__) == "README-2" + + +def test_next_free_name_gives_up(): + with pytest.raises(RuntimeError): + next_free_name("x", ".mp4", lambda n: True, limit=5) diff --git a/intake/tests/test_previews.py b/intake/tests/test_previews.py new file mode 100644 index 0000000..a4f89dc --- /dev/null +++ b/intake/tests/test_previews.py @@ -0,0 +1,161 @@ +import httpx +import pytest +from fastapi.testclient import TestClient + +from app.previews import ( + LONG_SECONDS, + Previews, + encode_cmd, + follow_progress, + niced, + target_bytes, + video_kbps, +) +from app.server import Settings, create_app +from tests.test_server import FakeZipline as UploadZipline +from tests.test_site_api import FakeZipline, login, mkfile + + +def test_bitrate_fits_the_target(): + for seconds in (30, 70, 300): + kbps = video_kbps(seconds) + assert (kbps + 128) * 1000 / 8 * seconds <= target_bytes(seconds) + assert video_kbps(10) == 4000 # short clips are capped, not blown up + assert video_kbps(3600) is None # an hour can't fit in 22 MB at any watchable quality + assert video_kbps(0) is None + + +def test_long_clips_get_a_smaller_file(): + assert target_bytes(LONG_SECONDS) == 22_000_000 + assert target_bytes(LONG_SECONDS + 1) == 18_000_000 + + +def test_encode_keeps_one_audio_track_and_720p_up_to_four_minutes(): + cmd = encode_cmd("http://zipline:3000/a.mp4", "out.mp4", 600, LONG_SECONDS) + assert cmd[cmd.index("-map") + 1:cmd.index("-map") + 4] == ["0:v:0", "-map", "0:a:0?"] + assert "scale=-2:'min(720,ih)'" in cmd and "+faststart" in cmd + assert "scale=-2:'min(480,ih)'" in encode_cmd("x", "y", 600, LONG_SECONDS + 1) + + +def test_encode_runs_at_low_priority_when_nice_exists(monkeypatch): + monkeypatch.setattr("app.previews.shutil.which", lambda name: "/usr/bin/nice") + assert niced(["ffmpeg", "-i", "x"]) == ["nice", "-n", "10", "ffmpeg", "-i", "x"] + monkeypatch.setattr("app.previews.shutil.which", lambda name: None) + assert niced(["ffmpeg"]) == ["ffmpeg"] + + +def test_small_clips_get_no_copy(tmp_path, monkeypatch): + p = Previews(tmp_path, "http://zipline:3000", over_bytes=1000) + monkeypatch.setattr(p, "make", lambda *a: pytest.fail("should not encode")) + p.request("small.mp4", 999) + assert p.jobs.empty() and not p.pending + + +def test_failed_clip_is_not_retried(tmp_path): + p = Previews(tmp_path, "http://zipline:3000", over_bytes=10) + (tmp_path / f"{Previews.key('big.mp4', 50)}.failed").touch() + p.request("big.mp4", 50) + assert p.jobs.empty() + + +def test_key_changes_with_size_and_rejects_paths(tmp_path): + assert Previews.key("a.mp4", 1) != Previews.key("a.mp4", 2) + p = Previews(tmp_path, "http://zipline:3000", over_bytes=10) + assert p.file("../secret") is None + + +@pytest.fixture +def setup(tmp_path, monkeypatch): + zl = FakeZipline() + zl.files["tok-alice"].append(mkfile("e9", "big.mp4", size=5000)) + asked = [] + monkeypatch.setattr(Previews, "request", lambda self, name, length: asked.append((name, length))) + monkeypatch.setattr("app.site_api.video_size", lambda url, length: (1280, 720)) + settings = Settings(zipline_url="http://zipline:3000", site_origin="https://clips.example.com", + upload_url="https://upload.example.com", max_bytes=10_000, work_dir=tmp_path, preview_over=1000) + client = TestClient(create_app(settings, transport=httpx.MockTransport(zl.handler)), base_url="https://testserver") + return client, asked, tmp_path / "previews" + + +def test_big_clip_without_copy_links_the_original_and_asks_for_one(setup): + client, asked, _ = setup + r = client.get("/v/big.mp4") + assert '' in r.text + assert asked == [("big.mp4", 5000)] + + +def test_big_clip_with_copy_links_the_copy(setup): + client, asked, folder = setup + key = Previews.key("big.mp4", 5000) + folder.mkdir() + (folder / f"{key}.mp4").write_bytes(b"0123456789") + r = client.get("/v/big.mp4") + assert f'' in r.text + assert '' in r.text + assert asked == [] + got = client.get(f"/_site/preview/{key}.mp4", headers={"range": "bytes=2-5"}) + assert got.status_code == 206 and got.content == b"2345" + assert got.headers["content-type"] == "video/mp4" + assert client.get("/_site/preview/" + "0" * 24 + ".mp4").status_code == 404 + + +def test_upload_of_a_video_asks_for_a_copy(tmp_path, monkeypatch): + asked = [] + monkeypatch.setattr(Previews, "request", lambda self, name, length: asked.append((name, length))) + settings = Settings(zipline_url="http://zipline:3000", site_origin="https://clips.example.com", + upload_url="https://upload.example.com", max_bytes=10_000, work_dir=tmp_path, preview_over=1000) + client = TestClient(create_app(settings, transport=httpx.MockTransport(UploadZipline().handler))) + for name, kind in (("clip.mp4", "video/mp4"), ("notes.zip", "application/zip")): + r = client.post("/upload", headers={"authorization": "tok"}, files={"file": (name, b"x" * 1500, kind)}) + assert r.status_code == 200 + assert asked == [("clip.mp4", 1500)] + + +def test_rename_carries_the_copy_over(setup): + client, _, folder = setup + folder.mkdir() + (folder / f"{Previews.key('big.mp4', 5000)}.mp4").write_bytes(b"copy") + login(client) + assert client.patch("/_site/api/files/e9", json={"name": "renamed.mp4"}).json() == {"name": "renamed.mp4"} + assert (folder / f"{Previews.key('renamed.mp4', 5000)}.mp4").read_bytes() == b"copy" + assert not (folder / f"{Previews.key('big.mp4', 5000)}.mp4").exists() + r = client.get("/v/renamed.mp4") + assert f"/_site/preview/{Previews.key('renamed.mp4', 5000)}.mp4" in r.text + + +def test_discord_status_tells_the_site_when_the_link_is_safe(setup): + client, asked, folder = setup + get = lambda name: client.get(f"/_site/api/discord/{name}").json() + assert get("clutch.mp4") == {"state": "small", "progress": 1.0, "queued": False} # plays as is + assert get("big.mp4") == {"state": "making", "progress": 0.0, "queued": True} + assert asked == [("big.mp4", 5000)] + folder.mkdir() + key = Previews.key("big.mp4", 5000) + (folder / f"{key}.failed").touch() + assert get("big.mp4")["state"] == "none" + (folder / f"{key}.mp4").write_bytes(b"copy") + assert get("big.mp4") == {"state": "ready", "progress": 1.0, "queued": False} + assert get("nope.mp4")["state"] == "small" + + +def test_status_shows_the_encode_progress_and_the_queue(tmp_path): + p = Previews(tmp_path, "http://zipline:3000", over_bytes=10) + p.worker = type("Busy", (), {"is_alive": lambda self: True})() # a running worker: request() only queues + p.request("a.mp4", 50) + p.request("b.mp4", 50) + p.current, p.progress = Previews.key("a.mp4", 50), 0.42 + assert p.status("a.mp4", 50) == {"state": "making", "progress": 0.42, "queued": False} + assert p.status("b.mp4", 50) == {"state": "making", "progress": 0.0, "queued": True} + + +def test_follow_progress_reads_ffmpeg_progress_lines(): + seen = [] + lines = ["frame=10", "out_time_us=5000000", "progress=continue", "out_time_us=N/A", + "out_time_us=9000000", "out_time_us=12000000", "progress=end"] + follow_progress(lines, 10.0, seen.append) + assert seen == [0.5, 0.9, 1.0] + + +def test_encode_reports_progress(): + cmd = encode_cmd("x", "y", 600, 30) + assert cmd[cmd.index("-progress") + 1] == "pipe:1" and "-nostats" in cmd diff --git a/intake/tests/test_remux.py b/intake/tests/test_remux.py new file mode 100644 index 0000000..6819b53 --- /dev/null +++ b/intake/tests/test_remux.py @@ -0,0 +1,40 @@ +import shutil +import subprocess + +import pytest + +from app.remux import remux_to_mp4 + +needs_ffmpeg = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not installed") + + +def make_mkv(path): + subprocess.run( + ["ffmpeg", "-v", "error", "-y", + "-f", "lavfi", "-i", "testsrc=size=320x240:rate=30", + "-f", "lavfi", "-i", "sine=frequency=440", + "-t", "1", "-c:v", "libx264", "-c:a", "aac", str(path)], + check=True, + ) + + +@needs_ffmpeg +def test_remux_good_mkv(tmp_path): + src, dst = tmp_path / "in.mkv", tmp_path / "out.mp4" + make_mkv(src) + assert remux_to_mp4(src, dst) is True + assert dst.read_bytes()[4:8] == b"ftyp" + + +def test_remux_garbage_returns_false(tmp_path): + src, dst = tmp_path / "in.mkv", tmp_path / "out.mp4" + src.write_bytes(b"not a video at all") + assert remux_to_mp4(src, dst) is False + assert not dst.exists() + + +def test_remux_missing_ffmpeg_returns_false(tmp_path, monkeypatch): + monkeypatch.setenv("PATH", "") + src, dst = tmp_path / "in.mkv", tmp_path / "out.mp4" + src.write_bytes(b"x") + assert remux_to_mp4(src, dst) is False diff --git a/intake/tests/test_server.py b/intake/tests/test_server.py new file mode 100644 index 0000000..81500d2 --- /dev/null +++ b/intake/tests/test_server.py @@ -0,0 +1,253 @@ +import shutil +import subprocess +from urllib.parse import quote, unquote + +import httpx +import pytest +from fastapi.testclient import TestClient + +from app.server import Settings, create_app + + +class FakeZipline: + def __init__(self): + self.names = set() # names Zipline already has + self.race = set() # free at HEAD time, taken by the time we upload + self.fail_with = None # force this status on /api/upload + self.raw_urls = False # return the url unencoded, like a careless server would + self.head_fails = False # HEAD raises, as if Zipline went away mid-request + self.uploads = [] + + def handler(self, request: httpx.Request) -> httpx.Response: + if request.method == "HEAD" and self.head_fails: + raise httpx.ConnectError("down") + if request.method == "HEAD": + name = unquote(request.url.path[1:]) + if name in self.names: + # Zipline redirects text files (and password-protected ones) to /view/ + return httpx.Response(302 if name.endswith(".txt") else 200) + return httpx.Response(404) + if request.url.path == "/api/upload": + if self.fail_with: + return httpx.Response(self.fail_with, json={"error": "boom"}) + stem = unquote(request.headers["x-zipline-filename"]) + name = stem + request.headers.get("x-zipline-file-extension", "") + if name in self.names or name in self.race: + self.race.discard(name) + self.names.add(name) + return httpx.Response(400, json={"error": "file with the same name already exists"}) + self.names.add(name) + self.uploads.append({"name": name, "headers": dict(request.headers), "body": request.read()}) + return httpx.Response(200, json={"files": [{ + "id": "1", "name": name, "type": "video/mp4", + "url": f"https://clips.example.com/{name if self.raw_urls else quote(name)}", + }]}) + return httpx.Response(404) + + +@pytest.fixture +def zl(): + return FakeZipline() + + +@pytest.fixture +def client(zl, tmp_path): + settings = Settings(zipline_url="http://zipline:3000", site_origin="https://clips.example.com", + upload_url="https://upload.example.com", max_bytes=1000, deletes_at="90d", work_dir=tmp_path) + return TestClient(create_app(settings, transport=httpx.MockTransport(zl.handler))) + + +AUTH = {"Authorization": "tok"} + + +def up(client, name, data=b"x" * 10, headers=AUTH): + return client.post("/upload", files={"file": (name, data, "application/octet-stream")}, headers=headers) + + +def test_health(client): + assert client.get("/health").json() == {"ok": True} + + +def test_missing_token_is_401(client, zl): + r = up(client, "a.mp4", headers={}) + assert r.status_code == 401 + assert zl.uploads == [] + + +def test_missing_file_field_is_400(client): + r = client.post("/upload", data={"nope": "1"}, headers=AUTH) + assert r.status_code == 400 + + +def test_spaces_become_dashes_and_headers(client, zl): + r = up(client, "my clip 1.mp4") + assert r.status_code == 200 + assert r.json() == {"url": "https://clips.example.com/my-clip-1.mp4", "name": "my-clip-1.mp4", "remuxed": False} + h = zl.uploads[0]["headers"] + assert h["authorization"] == "tok" + assert h["x-zipline-format"] == "name" + assert h["x-zipline-filename"] == "my-clip-1" + assert h["x-zipline-file-extension"] == ".mp4" + assert h["x-zipline-deletes-at"] == "90d" + assert h["x-zipline-original-name"] == "true" + assert zl.uploads[0]["body"].count(b"x" * 10) == 1 + + +def test_duplicate_gets_suffix(client, zl): + zl.names |= {"clip.mp4", "clip-2.mp4"} + assert up(client, "clip.mp4").json()["name"] == "clip-3.mp4" + + +def test_taken_text_file_redirect_counts_as_taken(client, zl): + zl.names |= {"n.txt", "n-2.txt", "n-3.txt"} + r = up(client, "n.txt") + assert r.status_code == 200 + assert r.json()["name"] == "n-4.txt" + + +def test_race_retries_next_name(client, zl): + zl.race.add("clip.mp4") + r = up(client, "clip.mp4") + assert r.status_code == 200 + assert r.json()["name"] == "clip-2.mp4" + + +def test_unicode_and_special_chars(client, zl): + r = up(client, "çılgın an #1.mp4") + assert r.status_code == 200 + assert r.json()["name"] == "çılgın-an-#1.mp4" + assert zl.uploads[0]["headers"]["x-zipline-filename"] == quote("çılgın-an-#1") + + +def test_no_extension(client, zl): + r = up(client, "README") + assert r.json()["name"] == "README" + assert "x-zipline-file-extension" not in zl.uploads[0]["headers"] + + +def test_exact_limit_ok(client): + assert up(client, "a.bin", data=b"x" * 1000).status_code == 200 + + +def test_one_byte_over_is_413(client, zl): + r = up(client, "a.bin", data=b"x" * 1001) + assert r.status_code == 413 + assert r.json()["error"] == f"file is larger than the {1000 / 1024**3:g} GB limit" + assert zl.uploads == [] + + +def test_huge_content_length_rejected_early(client, zl): + r = client.post("/upload", content=b"x", headers={**AUTH, "content-length": str(10**12), + "content-type": "multipart/form-data; boundary=b"}) + assert r.status_code == 413 + + +def test_zipline_error_is_502_and_cleans_up(client, zl, tmp_path): + zl.fail_with = 500 + r = up(client, "a b.mkv", data=b"garbage") + assert r.status_code == 502 + assert list(tmp_path.iterdir()) == [] + + +def test_zipline_4xx_passed_through(client, zl): + zl.fail_with = 401 + assert up(client, "a.mp4").status_code == 401 + + +def test_bad_mkv_kept_as_mkv(client, zl): + r = up(client, "a b.mkv", data=b"not a video") + assert r.status_code == 200 + assert r.json() == {"url": "https://clips.example.com/a-b.mkv", "name": "a-b.mkv", "remuxed": False} + + +@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not installed") +def test_good_mkv_becomes_mp4(zl, tmp_path): + src = tmp_path / "src.mkv" + subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", "testsrc=size=320x240:rate=30", + "-t", "1", "-c:v", "libx264", str(src)], check=True) + work = tmp_path / "work" + work.mkdir() + settings = Settings(zipline_url="http://zipline:3000", site_origin="https://clips.example.com", + upload_url="https://upload.example.com", max_bytes=10**8, deletes_at="90d", work_dir=work) + c = TestClient(create_app(settings, transport=httpx.MockTransport(zl.handler))) + r = up(c, "my clip.mkv", data=src.read_bytes()) + assert r.json()["name"] == "my-clip.mp4" + assert r.json()["remuxed"] is True + assert zl.uploads[0]["headers"]["x-zipline-file-extension"] == ".mp4" + assert list(work.iterdir()) == [] + + +def test_generic_content_type_is_guessed_from_extension(client, zl): + # curl/ShareX send application/octet-stream; Discord only plays video/mp4 inline + assert up(client, "clip.mp4").status_code == 200 + assert b"Content-Type: video/mp4" in zl.uploads[0]["body"] + + +def test_unknown_extension_stays_octet_stream(client, zl): + assert up(client, "data.zzz9").status_code == 200 + assert b"Content-Type: application/octet-stream" in zl.uploads[0]["body"] + + +def test_upload_with_ticket(client, zl): + ticket = client.app.state.tickets.issue("tok-from-ticket") + r = up(client, "t.mp4", headers={"x-upload-ticket": ticket}) + assert r.status_code == 200 + assert zl.uploads[0]["headers"]["authorization"] == "tok-from-ticket" + + +def test_ticket_single_use(client, zl): + ticket = client.app.state.tickets.issue("tok") + assert up(client, "a.mp4", headers={"x-upload-ticket": ticket}).status_code == 200 + r = up(client, "b.mp4", headers={"x-upload-ticket": ticket}) + assert r.status_code == 401 + assert r.json() == {"error": "upload ticket expired, try again"} + assert len(zl.uploads) == 1 + + +def test_bad_ticket_is_401(client, zl): + assert up(client, "a.mp4", headers={"x-upload-ticket": "made-up"}).status_code == 401 + assert zl.uploads == [] + + +def test_cors_preflight_for_site_origin(client): + r = client.options("/upload", headers={ + "origin": "https://clips.example.com", + "access-control-request-method": "POST", + "access-control-request-headers": "x-upload-ticket", + }) + assert r.status_code == 200 + assert r.headers["access-control-allow-origin"] == "https://clips.example.com" + assert "x-upload-ticket" in r.headers["access-control-allow-headers"].lower() + + +def test_cors_refuses_other_origin(client): + r = client.options("/upload", headers={ + "origin": "https://evil.example", + "access-control-request-method": "POST", + }) + assert "access-control-allow-origin" not in r.headers + + +def test_cors_headers_on_error_responses(client): + r = up(client, "a.bin", data=b"x" * 1001, headers={**AUTH, "origin": "https://clips.example.com"}) + assert r.status_code == 413 + assert r.headers["access-control-allow-origin"] == "https://clips.example.com" + + +def test_link_is_built_from_the_name_not_ziplines_url(client, zl): + # a '#' left unencoded in Zipline's url would cut the shared link off + zl.raw_urls = True + r = up(client, "çılgın an #1.mp4") + assert r.json()["url"] == "https://clips.example.com/" + quote("çılgın-an-#1.mp4") + + +def test_zipline_down_during_name_check_is_502(client, zl): + zl.head_fails = True + r = up(client, "a.mp4") + assert r.status_code == 502 + + +def test_no_free_name_is_409(client, zl, monkeypatch): + from app import server + monkeypatch.setattr(server, "next_free_name", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("full"))) + assert up(client, "a.mp4").status_code == 409 diff --git a/intake/tests/test_site_api.py b/intake/tests/test_site_api.py new file mode 100644 index 0000000..ebb1239 --- /dev/null +++ b/intake/tests/test_site_api.py @@ -0,0 +1,382 @@ +import json +import math +import re +from urllib.parse import quote, unquote + +import httpx +import pytest +from fastapi.testclient import TestClient + +from app.server import Settings, create_app + + +def mkfile(fid, name, size=100, thumb=True): + return { + "id": fid, "name": name, "originalName": name, "size": size, "type": "video/mp4", "views": 3, + "createdAt": "2026-10-01T16:46:15.378Z", "deletesAt": "2026-12-30T16:46:15.375Z", + "thumbnail": {"path": f".thumbnail.{fid}.jpg"} if thumb else None, "tags": [], "url": "/" + name, + } + + +class FakeZipline: + def __init__(self): + self.users = {"alice": ("pw", "tok-alice"), "bob": ("pw2", "tok-bob")} + self.files = { + "tok-alice": [mkfile("e1", "clutch.mp4"), mkfile("e2", "çılgın-#1.mp4", thumb=False)], + "tok-bob": [mkfile("c1", "friend.mp4")], + } + self.down = False + self.calls = [] + + def owner(self, tok): + return next((name for name, (_, t) in self.users.items() if t == tok), None) + + def handler(self, request: httpx.Request) -> httpx.Response: + if self.down: + raise httpx.ConnectError("down") + path, method = request.url.path, request.method + self.calls.append((method, path, request.headers.get("authorization"), request.headers.get("cookie"))) + if path == "/api/auth/login": + body = json.loads(request.content) + user = self.users.get(body["username"]) + if not user or user[0] != body["password"]: + return httpx.Response(400, json={"error": "E1044: Invalid username or password"}) + return httpx.Response( + 200, json={"user": {"id": "u-" + body["username"], "username": body["username"]}}, + headers={"set-cookie": f"zipline_session=sess-{body['username']}; Max-Age=1209600; Path=/; HttpOnly; Secure; SameSite=Lax"}, + ) + if path == "/api/user/token": + cookie = request.headers.get("cookie") or "" + for name, (_, tok) in self.users.items(): + if f"zipline_session=sess-{name}" in cookie: + return httpx.Response(200, json={"token": tok}) + return httpx.Response(401, json={"error": "E2001: Invalid token"}) + if method == "HEAD": + name = unquote(path[1:]) + f = next((f for files in self.files.values() for f in files if f["name"] == name), None) + if f is None: + return httpx.Response(404) + return httpx.Response(200, headers={"content-type": f["type"], "content-length": str(f["size"])}) + tok = request.headers.get("authorization") + who = self.owner(tok) + if who is None: + return httpx.Response(401, json={"error": "E2001: Invalid token"}) + mine = self.files.setdefault(tok, []) + if path == "/api/user": + return httpx.Response(200, json={"user": {"id": "u-" + who, "username": who, "role": "USER"}}) + if path == "/api/user/stats": + return httpx.Response(200, json={"storageUsed": sum(f["size"] for f in mine)}) + if path == "/api/user/files": + page = int(request.url.params.get("page", "1")) + per = int(request.url.params.get("perpage", "15")) + chunk = mine[(page - 1) * per: page * per] + return httpx.Response(200, json={"page": chunk, "total": len(mine), "pages": max(1, math.ceil(len(mine) / per))}) + if path.startswith("/api/user/files/"): + ident = unquote(path[len("/api/user/files/"):]) + f = next((f for f in mine if ident in (f["id"], f["name"])), None) + if f is None: + return httpx.Response(404, json={"error": "E4000: File not found"}) + if method == "GET": + return httpx.Response(200, json=f) + if method == "PATCH": + f["name"] = json.loads(request.content)["name"] + return httpx.Response(200, json=f) + if method == "DELETE": + mine.remove(f) + return httpx.Response(200, json=f) + return httpx.Response(404, json={"error": "not found"}) + + +@pytest.fixture +def zl(): + return FakeZipline() + + +@pytest.fixture +def client(zl, tmp_path): + settings = Settings(zipline_url="http://zipline:3000", site_origin="https://clips.example.com", + upload_url="https://upload.example.com", max_bytes=1000, deletes_at="90d", work_dir=tmp_path) + return TestClient(create_app(settings, transport=httpx.MockTransport(zl.handler)), base_url="https://testserver") + + +def login(client, user="alice", pw="pw", ip="1.2.3.4"): + return client.post("/_site/api/login", json={"username": user, "password": pw}, headers={"cf-connecting-ip": ip}) + + +def test_pages_serve_the_site(client): + for path in ("/", "/v/clutch.mp4", "/v/%C3%A7%C4%B1lg%C4%B1n-%231.mp4"): + r = client.get(path) + assert r.status_code == 200 + assert "text/html" in r.headers["content-type"] + assert "/_site/static/app.js" in r.text + assert client.get("/_site/static/style.css").status_code == 200 + + +@pytest.fixture +def fake_media(monkeypatch): + seen = [] + monkeypatch.setattr("app.site_api.video_size", lambda url, length: seen.append(url) or (1920, 1080)) + monkeypatch.setattr("app.site_api.still_jpeg", lambda url, length: seen.append(url) or b"jpeg-bytes") + return seen + + +def test_watch_page_has_discord_player_tags(client, fake_media): + # Discord gives up on big raw links; the /v/ page hands it the player URL, size and a still instead + r = client.get("/v/%C3%A7%C4%B1lg%C4%B1n-%231.mp4") + clip = "https://clips.example.com/%C3%A7%C4%B1lg%C4%B1n-%231.mp4" + assert f'' in r.text + assert '' in r.text + assert '' in r.text + assert '' in r.text + assert ('') in r.text + assert fake_media == ["http://zipline:3000/%C3%A7%C4%B1lg%C4%B1n-%231.mp4"] + + +def test_shared_clip_gets_a_random_stripe_color(client, fake_media): + colors = {re.search(r'name="theme-color" content="(#[0-9a-f]{6})"', client.get("/v/clutch.mp4").text).group(1) + for _ in range(5)} + assert len(colors) > 1 and "#3e4637" not in colors + assert 'name="theme-color" content="#3e4637"' in client.get("/").text + + +def test_watch_page_without_clip_has_no_player_tags(client, fake_media): + for path in ("/v/nope.mp4", '/v/">'): + r = client.get(path) + assert r.status_code == 200 + assert "og:video" not in r.text and "