Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e42ee5f
Add S3 Support.
Nov 28, 2025
7866eac
Fix compilation errors.
Nov 28, 2025
714e84c
Add file upload and transcode progress bar.
Nov 30, 2025
aed6077
File will appear automatically.
Nov 30, 2025
09f9531
Separate PR #108 formatting from semantic changes for easier review
elonen Nov 30, 2025
1434507
Further reduce formatting-only changes from main.rs
elonen Nov 30, 2025
5368968
Fix S3 multipart upload short reads
elonen Dec 1, 2025
b87ca6b
Replace CLI arg passed S3 auth with S3 SDK's built-in auth cascade. A…
elonen Nov 30, 2025
536ad7e
Add S3 E2E test and macro support for custom storage
elonen Dec 1, 2025
9a27e25
Fix S3 storage runtime drop panic during server shutdown (=> dirty ex…
elonen Dec 1, 2025
2ffcd71
Merge remote-tracking branch 'origin/master' into feature/s3-support
elonen Jan 9, 2026
f309a26
Replace public S3 bucket design with presigned URLs, complete Chinese…
Mike-Solar Jul 3, 2026
363a402
Make default port configurable.
Mike-Solar Jul 3, 2026
1d85b15
Merge remote-tracking branch 'upstream/master' into feature/s3-support
Mike-Solar Jul 3, 2026
8243dfb
fix: resolve post-merge compilation and test blockers
Mike-Solar Jul 3, 2026
3cb515a
feat(i18n): complete zh/fi translation coverage and fix extraction
Mike-Solar Jul 4, 2026
7e5555f
Complete S3 backend: presigned URLs, retry logic, runtime safety, con…
Mike-Solar Jul 4, 2026
5e7d83c
Add unit tests for storage backend logic
Mike-Solar Jul 4, 2026
a6356e2
Add runtime-context unit test for storage upload wrapper
Mike-Solar Jul 4, 2026
eaa5f2d
Fix metadata_reader symlink fallback to use absolute source path
Mike-Solar Jul 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
dist_deb/
.DS_Store
.claude
.idea
.local-tmp/
.local-bin/
.idea/
.local-temp
.local-tmp/
.env
/minio
/mc
9 changes: 9 additions & 0 deletions Dockerfile.server
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ RUN apt-get -qy install protobuf-compiler >/dev/null
ARG PYTHON_VER=python3.13
RUN apt-get -qy install python3 ${PYTHON_VER} ${PYTHON_VER}-venv >/dev/null

# Install MinIO for S3 integration tests
RUN apt-get -qy install curl >/dev/null
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then MINIO_ARCH="amd64"; \
elif [ "$ARCH" = "arm64" ]; then MINIO_ARCH="arm64"; \
else echo "Unsupported architecture: $ARCH" && exit 1; fi && \
curl -fsSL https://dl.min.io/server/minio/release/linux-${MINIO_ARCH}/minio -o /usr/local/bin/minio && \
chmod +x /usr/local/bin/minio

# Switch to regular user
RUN mkdir -p /app
RUN chown -R ${UID}:${GID} /app
Expand Down
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ CLIENT_VER := $(shell $(MAKE) -s -C client get-cur-ver)
SERVER_VER := $(shell $(MAKE) -s -C server get-cur-ver)
ORG_VER := $(shell $(MAKE) -s -C organizer/basic_folders get-cur-ver)

CLAPSHOT_DOCKER_HOST ?= 127.0.0.1
CLAPSHOT_DOCKER_PORT ?= 8080
CLAPSHOT_URL_BASE ?= http://$(CLAPSHOT_DOCKER_HOST):$(CLAPSHOT_DOCKER_PORT)/

ifeq ($(TARGET_ARCH),)
ARCH=$(shell uname -m)
PLATFORM_STR =
Expand Down Expand Up @@ -106,7 +110,8 @@ run-docker: debian-docker
cp server/src/tests/assets/60fps-example.mp4 test/VOLUME/data/incoming/
@echo "Removing any existing Unix socket files for macOS Docker compatibility..."
rm -f test/VOLUME/data/grpc-srv-to-org.sock test/VOLUME/data/grpc-org-to-srv.sock
docker run --rm -it -p 0.0.0.0:8080:80 \
docker run --rm -it -p $(CLAPSHOT_DOCKER_HOST):$(CLAPSHOT_DOCKER_PORT):80 \
-e CLAPSHOT_SERVER__URL_BASE=$(CLAPSHOT_URL_BASE) \
--mount type=bind,source="$$(pwd)"/test/VOLUME,target=/mnt/clapshot-data \
--mount type=bind,source="$$(pwd)"/organizer/basic_folders/example_metaplugins,target=/opt/clapshot-org-bf-metaplugins,readonly \
clapshot-comb
Expand Down
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Mainly for organizations that can't or won't put their media in commercial cloud
- **File Organization**: Hierarchical folder system with drag-and-drop, admin user management interface
- **Media Processing**: FFmpeg transcoding with configurable quality, thumbnail generation
- **Authentication**: Reverse proxy integration supporting OAuth, JWT, Kerberos, SAML, etc.
- **Storage**: SQLite database with automatic migrations, file-based media storage
- **Storage**: SQLite database with automatic migrations, local filesystem or S3-compatible object storage
- **Extensibility**: Plugin system for custom workflows and integrations

*For a comprehensive feature list, see [FEATURES.md](FEATURES.md).*
Expand Down Expand Up @@ -155,6 +155,44 @@ See [Upgrading Guide](doc/upgrading.md) for instructions on installing a new rel
**Using Slack?** An optional [Slack unfurl bot](extras/clapshot-slack-unfurl/) is available in `extras/` — it runs alongside Clapshot and shows rich link previews (thumbnail, title, timecode) when Clapshot URLs are posted in Slack channels.


### Object Storage (S3-compatible)

Clapshot can upload processed media and thumbnails to an S3-compatible object store while still staging files locally under `<data_dir>/videos`.

Playback URLs now point at the Clapshot server (`/api/media/...`), which validates the user and returns a short-lived presigned S3 redirect. This means the bucket itself can remain private.

**Required settings** (CLI flags, `clapshot-server.conf`, or `CLAPSHOT_SERVER__*` env vars):
- `storage-backend = s3`
- `s3-bucket = clapshot-media`

For MinIO or other S3-compatible services you also need:
- `s3-endpoint = https://s3.example.com`

For AWS S3 leave `s3-endpoint` unset and configure `AWS_REGION`.

**Authentication** uses the standard AWS SDK credential chain (in order of precedence):
1. Environment variables: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`
2. AWS credentials file (`~/.aws/credentials`)
3. IAM instance roles (for EC2/ECS deployments)

**Optional settings:**
- `s3-prefix` – path inside the bucket (default: `videos`)
- `s3-presigned-url-expiry` – presigned redirect lifetime in seconds (default: `3600`)
- `s3-public-url` – deprecated, kept only for backward compatibility

**Docker env example:**
```bash
-e CLAPSHOT_SERVER__STORAGE_BACKEND=s3 \
-e CLAPSHOT_SERVER__S3_BUCKET=clapshot-media \
-e CLAPSHOT_SERVER__S3_REGION=us-east-1 \
-e AWS_ACCESS_KEY_ID=YOUR_KEY \
-e AWS_SECRET_ACCESS_KEY=YOUR_SECRET \
-e CLAPSHOT_SERVER__S3_PRESIGNED_URL_EXPIRY=3600
```

Keep enough local disk for staging uploads under `data_dir/videos`.


## Architecture Overview

**Core:** Clapshot Client (browser, connects via WebSocket) · Clapshot Server (Rust daemon) · Clapshot Organizer(s) (plugins in Python or any language).
Expand Down
Empty file.
3 changes: 2 additions & 1 deletion client/debian/additional_files/clapshot.nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ server {
alias /mnt/clapshot-data/data/videos;
}

# API (clapshot-server)
# API (clapshot-server). Also handles /api/media/{id}/{path} redirects
# to short-lived presigned S3 URLs when S3 storage is enabled.
location /api {
proxy_pass http://127.0.0.1:8095/api;

Expand Down
15 changes: 15 additions & 0 deletions client/scripts/i18n-extract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ function extractSvelte(file, src) {
if (n.type === 'CallExpression' && n.callee?.type === 'Identifier' && n.callee.name in MARKERS) {
handleCall(n.callee.name, n.arguments?.[0], n.arguments?.[1], 'estree', `${rel(file)}:${lineOf(src, n.start)}`, warn);
}
// Also handle imperative use: get(t)("msgid", opts) or get(tc)("ctx", "msgid")
if (n.type === 'CallExpression' && n.callee?.type === 'CallExpression' && n.callee.callee?.type === 'Identifier' && n.callee.callee.name === 'get' && n.callee.arguments?.[0]?.type === 'Identifier') {
const innerName = n.callee.arguments[0].name;
if (innerName in MARKERS) {
handleCall(innerName, n.arguments?.[0], n.arguments?.[1], 'estree', `${rel(file)}:${lineOf(src, n.start)}`, warn);
}
}
for (const k in n) if (k !== 'start' && k !== 'end' && k !== 'loc') walk(n[k]);
})(ast);
}
Expand All @@ -99,6 +106,14 @@ function extractTs(file, src) {
const line = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
handleCall(node.expression.text, node.arguments[0], node.arguments[1], 'ts', `${rel(file)}:${line}`, warn);
}
// Also handle imperative use: get(t)("msgid", opts) or get(tc)("ctx", "msgid")
if (ts.isCallExpression(node) && ts.isCallExpression(node.expression) && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === 'get' && node.expression.arguments.length > 0 && ts.isIdentifier(node.expression.arguments[0])) {
const innerName = node.expression.arguments[0].text;
if (innerName in MARKERS) {
const line = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
handleCall(innerName, node.arguments[0], node.arguments[1], 'ts', `${rel(file)}:${line}`, warn);
}
}
ts.forEachChild(node, visit);
})(sf);
}
Expand Down
29 changes: 15 additions & 14 deletions client/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as Proto3 from '@clapshot_protobuf/typescript';
import {allComments, curUsername, curUserId, videoIsReady, mediaFileId, curVideo, curPageId, curPageItems, userMessages, latestProgressReports, collabId, userMenuItems, serverDefinedActions, curUserIsAdmin, connectionErrors, curSubtitle, clientConfig, playerHeaderHtml} from './stores';
import {IndentedComment, type UserMenuItem, type StringMap, type MediaProgressReport} from "./types";
import { t, initLocale, locale, currentLocale } from './i18n';
import { get } from 'svelte/store';

import CommentCard from './lib/player_view/CommentCard.svelte'
import SubtitleCard from './lib/player_view/SubtitleCard.svelte';
Expand Down Expand Up @@ -224,7 +225,7 @@ function activateComment(e: any) {

if (!c) {
console.warn("Comment not found:", commentId);
acts.add({mode: 'warning', message: 'Comment not found', lifetime: 3});
acts.add({mode: 'warning', message: $t("Comment not found"), lifetime: 3});
return;
}

Expand Down Expand Up @@ -265,7 +266,7 @@ function onSubtitleChange(e: any) {
$curSubtitle = $curVideo.subtitles.find((s) => s.id == sub_id) ?? null;
if ($curSubtitle == null && sub_id != null) {
console.error("Subtitle not found: ", sub_id);
acts.add({mode: 'error', message: "Subtitle not found. See log.", lifetime: 5});
acts.add({mode: 'error', message: $t("Subtitle not found. See log."), lifetime: 5});
}
}
if ($collabId) {
Expand Down Expand Up @@ -306,7 +307,7 @@ async function onUploadSubtitles() {
}});
} catch (e) {
console.error('Error adding subtitle file:', e);
acts.add({mode: 'error', message: 'Error adding subtitle file. See log.', lifetime: 5});
acts.add({mode: 'error', message: $t("Error adding subtitle file. See log."), lifetime: 5});
}
};
reader.readAsDataURL(file);
Expand Down Expand Up @@ -395,7 +396,7 @@ const urlParams = new URLSearchParams(window.location.search);
urlParams.forEach((value, key) => {
if (key != "vid" && key != "collab" && key != "p") {
console.error("Got UNKNOWN URL parameter: '" + key + "'. Value= " + value);
acts.add({mode: 'warn', message: "Unknown URL parameter: '" + key + "'", lifetime: 5});
acts.add({mode: 'warn', message: $t("Unknown URL parameter: '{key}'", { key }), lifetime: 5});
}
});

Expand Down Expand Up @@ -691,7 +692,7 @@ function connectWebsocketAfterAuthCheck(ws_url: string)
// log message, fileName, lineNumber
console.error("Exception in Websocket handler: ", e);
console.log(e.stack);
acts.add({mode: 'danger', message: 'Client error: ' + e, lifetime: 5});
acts.add({mode: 'danger', message: $t("Client error: {error}", { error: e }), lifetime: 5});
}
}

Expand Down Expand Up @@ -749,7 +750,7 @@ function connectWebsocketAfterAuthCheck(ws_url: string)

if (!cmd.welcome.user) {
console.error("No user in welcome message");
acts.add({mode: 'danger', message: 'No user in welcome message', lifetime: 5});
acts.add({mode: 'danger', message: $t("No user in welcome message"), lifetime: 5});
return;
}
$curUsername = cmd.welcome.user.name ?? cmd.welcome.user.id;
Expand Down Expand Up @@ -779,11 +780,11 @@ function connectWebsocketAfterAuthCheck(ws_url: string)
if (newPageId !== null) {
console.debug("[Browser history] Pushing new page state: ", newPageId);
history.pushState({pageId: newPageId}, '', `/?p=${encodeURIComponent(newPageId)}`);
document.title = "Clapshot - " + (cmd.showPage.pageTitle ?? newPageId);
document.title = get(t)("Clapshot - {title}", { title: cmd.showPage.pageTitle ?? newPageId });
} else {
console.debug("[Browser history] Pushing empty state (default page)");
history.pushState({pageId: null}, '', './');
document.title = "Clapshot - Home";
document.title = "Clapshot - " + $t("Home");
}
}

Expand Down Expand Up @@ -873,7 +874,7 @@ function connectWebsocketAfterAuthCheck(ws_url: string)
console.debug("[Browser history] In-player switch, replacing media file state: ", v.id);
history.replaceState({mediaFileId: v.id}, '', `/?vid=${v.id}`);
}
document.title = "Clapshot - " + (v.title ?? v.id);
document.title = get(t)("Clapshot - {title}", { title: v.title ?? v.id });
}

$mediaFileId = v.id;
Expand Down Expand Up @@ -936,7 +937,7 @@ function connectWebsocketAfterAuthCheck(ws_url: string)
}
if (lastCollabControllingUser != evt.fromUser) {
lastCollabControllingUser = evt.fromUser;
acts.add({mode: 'info', message: lastCollabControllingUser + " is controlling", lifetime: 5});
acts.add({mode: 'info', message: $t("{user} is controlling", { user: lastCollabControllingUser }), lifetime: 5});
}
}
// setCookies
Expand Down Expand Up @@ -977,7 +978,7 @@ function openMediaFileListItem(e: { detail: { item: Proto3.PageItem_FolderListin
});
} else {
console.error("No openAction script for item: " + item);
acts.add({mode: 'error', message: "No open action for item. See log.", lifetime: 5});
acts.add({mode: 'error', message: $t("No open action for item. See log."), lifetime: 5});
}
}

Expand Down Expand Up @@ -1017,7 +1018,7 @@ function callOrganizerScript(script: Proto3.ScriptCall|undefined, action_args: O
}
if (script.lang != Proto3.ScriptCall_Lang.JAVASCRIPT ) {
console.error("BUG: Unsupported Organizer script language: " + script.lang);
acts.add({mode: 'error', message: "BUG: Unsupported script lang. See log.", lifetime: 5});
acts.add({mode: 'error', message: $t("BUG: Unsupported script lang. See log."), lifetime: 5});
return;
}
const Function = function () {}.constructor;
Expand All @@ -1028,7 +1029,7 @@ function callOrganizerScript(script: Proto3.ScriptCall|undefined, action_args: O
scriptFn(action_args);
} catch (e: any) {
console.error("Error in organizer script:", e);
acts.add({mode: 'error', message: "Organizer script error. See log.", lifetime: 5});
acts.add({mode: 'error', message: $t("Organizer script error. See log."), lifetime: 5});
}
}

Expand Down Expand Up @@ -1126,7 +1127,7 @@ function onMediaFileListPopupAction(e: { detail: { action: Proto3.ActionDef, ite
<!-- Subtitles -->
<div class="flex justify-between text-gray-500 items-center py-2 border-t border-gray-500">
<h6>{$t("Subtitles")}</h6>
<button class="fa fa-plus-circle" title="Upload subtitles" aria-label="Upload subtitles" onclick={onUploadSubtitles}></button>
<button class="fa fa-plus-circle" title={$t("Upload subtitles")} aria-label={$t("Upload subtitles")} onclick={onUploadSubtitles}></button>
</div>
{#each $curVideo.subtitles as sub}
<SubtitleCard
Expand Down
Loading
Loading