diff --git a/.gitignore b/.gitignore index 0c4d7492..4025f882 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,11 @@ dist_deb/ .DS_Store .claude +.idea +.local-tmp/ +.local-bin/ .idea/ .local-temp -.local-tmp/ .env +/minio +/mc diff --git a/Dockerfile.server b/Dockerfile.server index 06aaef18..77d9ea9d 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -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 diff --git a/Makefile b/Makefile index cb68ee80..b598de53 100644 --- a/Makefile +++ b/Makefile @@ -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 = @@ -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 diff --git a/README.md b/README.md index e69e93a4..a2941190 100644 --- a/README.md +++ b/README.md @@ -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).* @@ -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 `/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). diff --git a/client/debian/additional_files/clapshot+htadmin.nginx.conf b/client/debian/additional_files/clapshot+htadmin.nginx.conf new file mode 100644 index 00000000..e69de29b diff --git a/client/debian/additional_files/clapshot.nginx.conf b/client/debian/additional_files/clapshot.nginx.conf index f6f71607..bc2c0252 100644 --- a/client/debian/additional_files/clapshot.nginx.conf +++ b/client/debian/additional_files/clapshot.nginx.conf @@ -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; diff --git a/client/scripts/i18n-extract.mjs b/client/scripts/i18n-extract.mjs index 33608c4d..f210c616 100644 --- a/client/scripts/i18n-extract.mjs +++ b/client/scripts/i18n-extract.mjs @@ -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); } @@ -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); } diff --git a/client/src/App.svelte b/client/src/App.svelte index 147069a0..9509d5c7 100644 --- a/client/src/App.svelte +++ b/client/src/App.svelte @@ -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'; @@ -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; } @@ -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) { @@ -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); @@ -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}); } }); @@ -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}); } } @@ -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; @@ -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"); } } @@ -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; @@ -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 @@ -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}); } } @@ -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; @@ -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}); } } @@ -1126,7 +1127,7 @@ function onMediaFileListPopupAction(e: { detail: { action: Proto3.ActionDef, ite
{$t("Subtitles")}
- +
{#each $curVideo.subtitles as sub} { + it('defaults to date mode when no sort mode is given', () => { + const items = [ + makeComment({ id: 'c2', created: new Date('2024-01-03') }), + makeComment({ id: 'c1', created: new Date('2024-01-01') }), + ]; + const result = indentCommentTree(items); + expect(result.map(r => r.comment.id)).toEqual(['c1', 'c2']); + }); + + it('preserves existing indent values in the output', () => { + const items = [ + { ...makeComment({ id: 'root' }), indent: 0 }, + { ...makeComment({ id: 'child', parentId: 'root' }), indent: 1 }, + ]; + const result = indentCommentTree(items, 'date'); + expect(result[0].indent).toBe(0); + expect(result[1].indent).toBe(1); + }); + + it('handles deeply nested replies', () => { + const items = [ + makeComment({ id: 'r', created: new Date('2024-01-01') }), + makeComment({ id: 'c1', parentId: 'r', created: new Date('2024-01-02') }), + makeComment({ id: 'c2', parentId: 'c1', created: new Date('2024-01-03') }), + makeComment({ id: 'c3', parentId: 'c2', created: new Date('2024-01-04') }), + ]; + const result = indentCommentTree(items, 'date'); + expect(result.map(r => ({ id: r.comment.id, indent: r.indent }))).toEqual([ + { id: 'r', indent: 0 }, + { id: 'c1', indent: 1 }, + { id: 'c2', indent: 2 }, + { id: 'c3', indent: 3 }, + ]); + }); + + it('sorts root comments by timecode ASC in timecode mode', () => { + const items = [ + makeComment({ id: 'c3', timecode: '00:03:00.000' }), + makeComment({ id: 'c1', timecode: '00:01:00.000' }), + makeComment({ id: 'c2', timecode: '00:02:00.000' }), + ]; + const result = indentCommentTree(items, 'timecode'); + expect(result.map(r => r.comment.id)).toEqual(['c1', 'c2', 'c3']); + }); + + it('places non-timed roots before timed roots and sorts by created date', () => { + const items = [ + makeComment({ id: 't1', timecode: '00:01:00.000', created: new Date('2024-01-01') }), + makeComment({ id: 'u2', created: new Date('2024-01-03') }), + makeComment({ id: 'u1', created: new Date('2024-01-02') }), + ]; + const result = indentCommentTree(items, 'timecode'); + expect(result.map(r => r.comment.id)).toEqual(['u1', 'u2', 't1']); + }); + + it('tiebreaks timed roots by created date when timecodes match', () => { + const items = [ + makeComment({ id: 'c2', timecode: '00:01:00.000', created: new Date('2024-01-05') }), + makeComment({ id: 'c1', timecode: '00:01:00.000', created: new Date('2024-01-02') }), + makeComment({ id: 'c3', timecode: '00:01:00.000', created: new Date('2024-01-03') }), + ]; + const result = indentCommentTree(items, 'timecode'); + expect(result.map(r => r.comment.id)).toEqual(['c1', 'c3', 'c2']); + }); + + it('sorts children by created date regardless of timecode mode', () => { + const items = [ + makeComment({ id: 'p', timecode: '00:05:00.000', created: new Date('2024-01-01') }), + makeComment({ id: 'c2', parentId: 'p', timecode: '00:01:00.000', created: new Date('2024-01-04') }), + makeComment({ id: 'c1', parentId: 'p', timecode: '00:09:00.000', created: new Date('2024-01-02') }), + ]; + const result = indentCommentTree(items, 'timecode'); + expect(result.map(r => r.comment.id)).toEqual(['p', 'c1', 'c2']); + }); + + it('appends orphaned comments to the end without modifying indent', () => { + const items = [ + makeComment({ id: 'root', created: new Date('2024-01-01') }), + makeComment({ id: 'orphan', parentId: 'missing', created: new Date('2024-01-02') }), + ]; + const result = indentCommentTree(items, 'date'); + expect(result.map(r => r.comment.id)).toEqual(['root', 'orphan']); + expect(result[1].indent).toBe(0); + }); + + it('handles a circular parent reference without infinite looping', () => { + const items = [ + makeComment({ id: 'a', parentId: 'b' }), + makeComment({ id: 'b', parentId: 'a' }), + ]; + const result = indentCommentTree(items, 'date'); + // Both are roots (parentId != null) and neither is found as root, so they are appended as orphans + expect(result).toHaveLength(2); + expect(result.map(r => r.comment.id)).toContain('a'); + expect(result.map(r => r.comment.id)).toContain('b'); + }); + + it('handles an empty comment list', () => { + expect(indentCommentTree([], 'date')).toEqual([]); + expect(indentCommentTree([], 'timecode')).toEqual([]); + }); + + it('handles a single root comment', () => { + const items = [makeComment({ id: 'only' })]; + const result = indentCommentTree(items, 'date'); + expect(result).toHaveLength(1); + expect(result[0].comment.id).toBe('only'); + expect(result[0].indent).toBe(0); + }); +}); + +describe('countTimedRootComments', () => { + it('returns 0 for an empty list', () => { + expect(countTimedRootComments([])).toBe(0); + }); + + it('counts only root-level timed comments', () => { + const items = [ + { ...makeComment({ id: 'root-timed', timecode: '00:01:00.000' }), indent: 0 }, + { ...makeComment({ id: 'root-untimed' }), indent: 0 }, + { ...makeComment({ id: 'child-timed', timecode: '00:02:00.000' }), indent: 1 }, + ]; + expect(countTimedRootComments(items)).toBe(1); + }); + + it('does not count invalid timecodes as timed', () => { + const items = [ + { ...makeComment({ id: 'bad', timecode: 'not-a-timecode' }), indent: 0 }, + { ...makeComment({ id: 'zero', timecode: '00:00:00.000' }), indent: 0 }, + ]; + expect(countTimedRootComments(items)).toBe(1); + }); +}); diff --git a/client/src/__tests__/cookies.test.ts b/client/src/__tests__/cookies.test.ts new file mode 100644 index 00000000..c5f67089 --- /dev/null +++ b/client/src/__tests__/cookies.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Cookies from '@/cookies'; + +describe('LocalStorageCookies', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + }); + + describe('set / get', () => { + it('stores and retrieves a cookie value', () => { + const now = Date.now(); + Cookies.set('session', 'abc123', now + 10000); + expect(Cookies.get('session')).toBe('abc123'); + }); + + it('returns null for a missing key', () => { + expect(Cookies.get('missing')).toBeNull(); + }); + + it('uses a default 12-hour expiration when none is provided', () => { + const before = Date.now(); + Cookies.set('session', 'abc123', null as any); + const stored = JSON.parse(localStorage.setItem.mock.calls.at(-1)[1]); + const expiration = stored['session'].expiration; + expect(expiration).toBeGreaterThanOrEqual(before + 60 * 60 * 12); + expect(expiration).toBeLessThanOrEqual(Date.now() + 60 * 60 * 12 + 1000); + }); + + it('stores an empty value as an empty string', () => { + Cookies.set('session', 'abc123', Date.now() + 10000); + expect(Cookies.get('session')).toBe('abc123'); + Cookies.set('session', '', Date.now() + 10000); + expect(Cookies.get('session')).toBe(''); + }); + + it('does not store a cookie with an empty key', () => { + const before = localStorage.setItem.mock.calls.length; + Cookies.set('', 'value', Date.now() + 10000); + // Empty key branch still calls delete and setItem, but no key is added + const stored = JSON.parse(localStorage.setItem.mock.calls.at(-1)[1]); + expect(stored).toEqual({}); + }); + }); + + describe('expiration', () => { + it('returns null and removes expired cookies on get', () => { + const now = Date.now(); + Cookies.set('session', 'abc123', now - 1); + expect(Cookies.get('session')).toBeNull(); + const stored = JSON.parse(localStorage.setItem.mock.calls.at(-1)[1]); + expect(stored).not.toHaveProperty('session'); + }); + + it('filters out expired cookies in getAllNonExpired', () => { + const now = Date.now(); + Cookies.set('fresh', 'yes', now + 10000); + Cookies.set('expired', 'no', now - 1); + const all = Cookies.getAllNonExpired(); + expect(all).toHaveProperty('fresh', 'yes'); + expect(all).not.toHaveProperty('expired'); + const stored = JSON.parse(localStorage.setItem.mock.calls.at(-1)[1]); + expect(stored).not.toHaveProperty('expired'); + }); + + it('returns an empty object when no cookies exist', () => { + expect(Cookies.getAllNonExpired()).toEqual({}); + }); + + it('returns only non-expired values keyed by name', () => { + const now = Date.now(); + Cookies.set('a', 'one', now + 10000); + Cookies.set('b', 'two', now + 20000); + const all = Cookies.getAllNonExpired(); + expect(Object.keys(all)).toEqual(['a', 'b']); + expect(all['a']).toBe('one'); + expect(all['b']).toBe('two'); + }); + }); + + describe('persistence', () => { + it('survives a fresh instance read from localStorage', () => { + Cookies.set('session', 'abc123', Date.now() + 10000); + // Re-importing the module is not practical because it is cached, + // but reading localStorage directly confirms the serialized shape. + const raw = localStorage.getItem('clapshot_state'); + const parsed = JSON.parse(raw!); + expect(parsed).toHaveProperty('session'); + expect(parsed['session']).toMatchObject({ value: 'abc123' }); + }); + }); +}); diff --git a/client/src/__tests__/lib/NavBar.test.ts b/client/src/__tests__/lib/NavBar.test.ts index 6f38134e..9d9137a7 100644 --- a/client/src/__tests__/lib/NavBar.test.ts +++ b/client/src/__tests__/lib/NavBar.test.ts @@ -431,6 +431,26 @@ describe('NavBar.svelte', () => { expect(matchingReport?.progress).toBe(0.7); }); + it('renders a progress bar for the active video', () => { + const report: MediaProgressReport = { + mediaFileId: 'video123', + msg: 'Uploading to storage…', + progress: 0.4, + received_ts: Date.now() + }; + mediaFileId.set('video123'); + curVideo.set(createMinimalMediaFile({ + id: 'video123', + title: 'Test Video' + })); + latestProgressReports.set([report]); + + const { container } = render(NavBar); + expect(screen.getAllByText('Uploading to storage…').length).toBeGreaterThan(0); + const bars = container.querySelectorAll('.bg-amber-500'); + expect(bars.length).toBeGreaterThan(0); + }); + it('should return undefined when no matching progress report', () => { const reports: MediaProgressReport[] = [ { @@ -523,4 +543,4 @@ describe('NavBar.svelte', () => { expect(() => render(NavBar)).not.toThrow(); }); }); -}); \ No newline at end of file +}); diff --git a/client/src/__tests__/lib/asset_browser/VideoTile.test.ts b/client/src/__tests__/lib/asset_browser/VideoTile.test.ts index f118173b..06776389 100644 --- a/client/src/__tests__/lib/asset_browser/VideoTile.test.ts +++ b/client/src/__tests__/lib/asset_browser/VideoTile.test.ts @@ -102,6 +102,19 @@ describe('VideoTile', () => { expect(container.querySelector('.flex-grow')).toBeInTheDocument(); }); + it('shows a default icon when no preview or visualization exists', () => { + const mediaFile = createMediaFile({ + id: 'no-preview', + title: 'No Preview Video', + previewData: undefined, + }); + + const { container } = render(VideoTile, { item: mediaFile }); + + const icon = container.querySelector('i.fa-video'); + expect(icon).toBeInTheDocument(); + }); + it('should render with visualization override when no preview data', () => { const mediaFile = createMediaFile({ id: 'test-video-3', @@ -354,4 +367,4 @@ describe('VideoTile', () => { expect(titleElement).toBeInTheDocument(); expect(titleElement.textContent).toBe(mediaFile.title); }); -}); \ No newline at end of file +}); diff --git a/client/src/__tests__/lib/tools/EDLImport.test.ts b/client/src/__tests__/lib/tools/EDLImport.test.ts new file mode 100644 index 00000000..84c0fcdb --- /dev/null +++ b/client/src/__tests__/lib/tools/EDLImport.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/svelte'; +import EDLImport from '@/lib/tools/EDLImport.svelte'; +import { curVideo } from '@/stores'; + +const sampleEDL = `TITLE: TEST EDIT +FCM: NON-DROP FRAME + +001 AX C 01:00:00:00 01:00:05:00 00:00:00:00 00:00:05:00 +* FROM CLIP NAME: Shot_01.mov + +002 AX C 01:00:10:00 01:00:15:00 00:00:05:00 00:00:10:00 +* FROM CLIP NAME: Shot_02.mov +`; + +const sampleEDLNoClipNames = `TITLE: TEST EDIT +FCM: NON-DROP FRAME + +001 AX C 01:00:00:00 01:00:05:00 00:00:00:00 00:00:05:00 +002 AX C 01:00:10:00 01:00:15:00 00:00:05:00 00:00:10:00 +`; + +const invalidEDL = `This is not an EDL file. +It has no timecodes at all. +`; + +describe('EDLImport.svelte', () => { + function findFileInput() { + return document.querySelector('input[type="file"]') as HTMLInputElement; + } + + function uploadFile(input: HTMLInputElement, file: File) { + fireEvent.change(input, { target: { files: [file] } }); + } + + beforeEach(() => { + curVideo.set({ id: 'video-1', duration: { fps: '30' } } as any); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('renders the import dialog when open', () => { + render(EDLImport, { props: { isOpen: true } }); + expect(screen.getByText('Import EDL as Comments')).toBeInTheDocument(); + expect(screen.getByLabelText('Upload EDL')).toBeInTheDocument(); + expect(screen.getByLabelText('Frame rate')).toBeInTheDocument(); + }); + + it('reads the frame rate from the current video', () => { + render(EDLImport, { props: { isOpen: true } }); + const fpsInput = screen.getByLabelText('Frame rate') as HTMLInputElement; + expect(fpsInput.value).toBe('30'); + }); + + it('falls back to 24fps when the video has no usable fps', async () => { + curVideo.set({ id: 'video-1', duration: { fps: 'not-a-number' } } as any); + render(EDLImport, { props: { isOpen: true } }); + const fpsInput = screen.getByLabelText('Frame rate') as HTMLInputElement; + await waitFor(() => expect(fpsInput.value).toBe('24')); + }); + + it('falls back to 24fps when there is no current video', async () => { + curVideo.set(null); + render(EDLImport, { props: { isOpen: true } }); + const fpsInput = screen.getByLabelText('Frame rate') as HTMLInputElement; + await waitFor(() => expect(fpsInput.value).toBe('24')); + }); + + it('parses a standard EDL file and lists the parsed spans', async () => { + render(EDLImport, { props: { isOpen: true } }); + const input = findFileInput(); + const file = new File([sampleEDL], 'test.edl', { type: 'text/plain' }); + + uploadFile(input, file); + + await waitFor(() => { + expect(screen.getByText('Parsed spans')).toBeInTheDocument(); + }); + expect(screen.getByText('00:00:00:00: Shot_01.mov')).toBeInTheDocument(); + expect(screen.getByText('00:00:05:00: Shot_02.mov')).toBeInTheDocument(); + }); + + it('falls back to the event number when FROM CLIP NAME is absent', async () => { + render(EDLImport, { props: { isOpen: true } }); + const input = findFileInput(); + const file = new File([sampleEDLNoClipNames], 'test.edl', { type: 'text/plain' }); + + uploadFile(input, file); + + await waitFor(() => { + expect(screen.getByText('00:00:00:00: 001')).toBeInTheDocument(); + expect(screen.getByText('00:00:05:00: 002')).toBeInTheDocument(); + }); + }); + + it('shows an error message for empty/invalid EDL input', async () => { + render(EDLImport, { props: { isOpen: true } }); + const input = findFileInput(); + const file = new File([invalidEDL], 'bad.edl', { type: 'text/plain' }); + + uploadFile(input, file); + + await waitFor(() => { + expect(screen.getByText('No time spans found when parsing EDL.')).toBeInTheDocument(); + }); + }); + + it('dispatches comments when Add as comments is clicked', async () => { + const onaddcomments = vi.fn(); + render(EDLImport, { + props: { isOpen: true, onaddcomments } + }); + const input = findFileInput(); + const file = new File([sampleEDL], 'test.edl', { type: 'text/plain' }); + + uploadFile(input, file); + + await waitFor(() => { + expect(screen.getByText('Add as comments')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Add as comments')); + + expect(onaddcomments).toHaveBeenCalledOnce(); + const comments = onaddcomments.mock.calls[0][0]; + expect(comments).toHaveLength(2); + expect(comments[0]).toMatchObject({ + mediaFileId: 'video-1', + timecode: '00:00:00:00', + comment: expect.stringContaining('Shot_01.mov'), + }); + expect(comments[1]).toMatchObject({ + mediaFileId: 'video-1', + timecode: '00:00:05:00', + comment: expect.stringContaining('Shot_02.mov'), + }); + }); + + it('does not dispatch comments when no spans were parsed', async () => { + const onaddcomments = vi.fn(); + render(EDLImport, { + props: { isOpen: true, onaddcomments } + }); + const input = findFileInput(); + const file = new File([invalidEDL], 'bad.edl', { type: 'text/plain' }); + + uploadFile(input, file); + + await waitFor(() => { + expect(screen.getByText('No time spans found when parsing EDL.')).toBeInTheDocument(); + }); + + expect(screen.queryByText('Add as comments')).not.toBeInTheDocument(); + expect(onaddcomments).not.toHaveBeenCalled(); + }); + + it('closes the dialog when Cancel is clicked', async () => { + let isOpen = true; + const { component } = render(EDLImport, { + props: { isOpen, onaddcomments: vi.fn() } + }); + const cancel = screen.getByText('Cancel'); + fireEvent.click(cancel); + // The internal bindable prop should be updated to false + expect(component.isOpen).toBe(false); + }); +}); diff --git a/client/src/__tests__/lib/tools/comment-export/ExportDialog.test.ts b/client/src/__tests__/lib/tools/comment-export/ExportDialog.test.ts new file mode 100644 index 00000000..682dda44 --- /dev/null +++ b/client/src/__tests__/lib/tools/comment-export/ExportDialog.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/svelte'; +import { tick } from 'svelte'; +import ExportDialog from '@/lib/tools/comment-export/ExportDialog.svelte'; +import { allComments, curVideo } from '@/stores'; +import { IndentedComment } from '@/types'; + +function makeComment(overrides: { + id?: string; + parentId?: string; + comment?: string; + timecode?: string; + drawing?: string; + usernameIfnull?: string; +}): IndentedComment { + return { + indent: overrides.parentId ? 1 : 0, + comment: { + id: overrides.id ?? 'c1', + mediaFileId: 'media-1', + usernameIfnull: overrides.usernameIfnull ?? 'User', + comment: overrides.comment ?? 'Test comment', + timecode: overrides.timecode, + parentId: overrides.parentId, + drawing: overrides.drawing, + }, + }; +} + +describe('ExportDialog.svelte', () => { + let createObjectURLSpy: ReturnType; + let revokeObjectURLSpy: ReturnType; + let clickSpy: ReturnType; + + beforeEach(() => { + allComments.set([]); + curVideo.set({ id: 'video-1', title: 'Test Video', duration: { fps: '30' } } as any); + + clickSpy = vi.fn(); + createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url'); + revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + + const originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag: string, options?: any) => { + const el = originalCreateElement(tag, options); + if (tag.toLowerCase() === 'a') { + Object.defineProperty(el, 'click', { value: clickSpy, configurable: true }); + } + return el; + }); + + const originalAppendChild = document.body.appendChild.bind(document.body); + vi.spyOn(document.body, 'appendChild').mockImplementation((node) => originalAppendChild(node)); + const originalRemoveChild = document.body.removeChild.bind(document.body); + vi.spyOn(document.body, 'removeChild').mockImplementation((node) => originalRemoveChild(node)); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('renders the export dialog when open', () => { + render(ExportDialog, { props: { isOpen: true } }); + expect(screen.getByText('Export Comments')).toBeInTheDocument(); + expect(screen.getByLabelText('Export format')).toBeInTheDocument(); + expect(screen.getByLabelText('Frame rate')).toBeInTheDocument(); + }); + + it('shows a message when there are no comments to export', () => { + render(ExportDialog, { props: { isOpen: true } }); + expect(screen.getByText('No comments to export.')).toBeInTheDocument(); + expect(screen.queryByText('Export')).not.toBeInTheDocument(); + }); + + it('shows the export button when comments are present', () => { + allComments.set([makeComment({ id: 'c1', comment: 'Hello' })]); + render(ExportDialog, { props: { isOpen: true } }); + expect(screen.getByText('Export')).toBeInTheDocument(); + expect(screen.getByText('1 comment will be exported.')).toBeInTheDocument(); + }); + + it('renders format-specific options for the default exporter', () => { + allComments.set([makeComment({ id: 'c1' })]); + render(ExportDialog, { props: { isOpen: true } }); + // Default exporter is resolve-edl, which has a marker color select + expect(screen.getByLabelText('Marker color')).toBeInTheDocument(); + }); + + it('updates options when a different format is selected', async () => { + allComments.set([makeComment({ id: 'c1' })]); + render(ExportDialog, { props: { isOpen: true } }); + + const formatSelect = screen.getByLabelText('Export format') as HTMLSelectElement; + // Switch to CSV + fireEvent.change(formatSelect, { target: { value: 'csv' } }); + await tick(); + + await waitFor(() => { + expect(screen.getByLabelText('Include header row')).toBeInTheDocument(); + expect(screen.getByLabelText('Delimiter')).toBeInTheDocument(); + expect(screen.queryByLabelText('Marker color')).not.toBeInTheDocument(); + }); + + // Switch to SRT + fireEvent.change(formatSelect, { target: { value: 'srt' } }); + await tick(); + await waitFor(() => { + expect(screen.getByLabelText('Duration per comment (seconds)')).toBeInTheDocument(); + expect(screen.queryByLabelText('Include header row')).not.toBeInTheDocument(); + }); + }); + + it('triggers a download when Export is clicked', async () => { + allComments.set([makeComment({ id: 'c1', comment: 'Hello world', timecode: '00:01:00.000' })]); + render(ExportDialog, { props: { isOpen: true } }); + + fireEvent.click(screen.getByText('Export')); + + await waitFor(() => { + expect(clickSpy).toHaveBeenCalledOnce(); + expect(createObjectURLSpy).toHaveBeenCalledOnce(); + expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url'); + }); + + const anchor = (document.createElement as any).mock.results.find( + (r: any) => r.value?.tagName === 'A' + )?.value as HTMLAnchorElement; + expect(anchor.download).toMatch(/^video-1\./); + }); + + it('uses the video id in the filename when no title is available', async () => { + curVideo.set({ id: 'video-2', duration: { fps: '24' } } as any); + allComments.set([makeComment({ id: 'c1' })]); + render(ExportDialog, { props: { isOpen: true } }); + + fireEvent.click(screen.getByText('Export')); + + await waitFor(() => { + const anchor = (document.createElement as any).mock.results.find( + (r: any) => r.value?.tagName === 'A' + )?.value as HTMLAnchorElement; + expect(anchor.download).toMatch(/^video-2\./); + }); + }); + + it('passes the configured frame rate to the exporter', async () => { + allComments.set([makeComment({ id: 'c1', timecode: '00:01:00:000' })]); + render(ExportDialog, { props: { isOpen: true } }); + + const fpsInput = screen.getByLabelText('Frame rate') as HTMLInputElement; + expect(fpsInput.value).toBe('30'); + + fireEvent.input(fpsInput, { target: { value: '60' } }); + fireEvent.click(screen.getByText('Export')); + + await waitFor(() => expect(clickSpy).toHaveBeenCalledOnce()); + }); + + it('closes the dialog when Cancel is clicked', async () => { + const { component } = render(ExportDialog, { props: { isOpen: true } }); + fireEvent.click(screen.getByText('Cancel')); + await waitFor(() => expect(component.isOpen).toBe(false)); + }); +}); diff --git a/client/src/__tests__/lib/tools/comment-export/index.test.ts b/client/src/__tests__/lib/tools/comment-export/index.test.ts new file mode 100644 index 00000000..3e614200 --- /dev/null +++ b/client/src/__tests__/lib/tools/comment-export/index.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { getExporterById, downloadFile, exporters, groupComments } from '@/lib/tools/comment-export/index'; + +describe('getExporterById', () => { + it('returns the exporter matching the given id', () => { + expect(getExporterById('srt')?.name).toBe('SRT Subtitles'); + expect(getExporterById('csv')?.name).toBe('CSV'); + expect(getExporterById('resolve-edl')?.name).toBe('DaVinci Resolve EDL'); + expect(getExporterById('premiere-xml')?.extension).toBe('.xml'); + expect(getExporterById('otio-notes')?.extension).toBe('.otrn'); + }); + + it('returns undefined for unknown exporter ids', () => { + expect(getExporterById('not-real')).toBeUndefined(); + expect(getExporterById('')).toBeUndefined(); + }); + + it('exposes every exporter in the registry', () => { + const ids = exporters.map(e => e.id); + expect(ids).toContain('resolve-edl'); + expect(ids).toContain('premiere-xml'); + expect(ids).toContain('srt'); + expect(ids).toContain('csv'); + expect(ids).toContain('otio-notes'); + expect(ids).toHaveLength(new Set(ids).size); + }); +}); + +describe('downloadFile', () => { + let createObjectURLSpy: ReturnType; + let revokeObjectURLSpy: ReturnType; + let clickSpy: ReturnType; + let appendChildSpy: ReturnType; + let removeChildSpy: ReturnType; + + beforeEach(() => { + clickSpy = vi.fn(); + createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url'); + revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + + // Mock anchor creation so we can inspect click/download/href + const originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag: string, options?: any) => { + const el = originalCreateElement(tag, options); + if (tag.toLowerCase() === 'a') { + Object.defineProperty(el, 'click', { value: clickSpy, configurable: true }); + } + return el; + }); + + appendChildSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node as any); + removeChildSpy = vi.spyOn(document.body, 'removeChild').mockImplementation((node) => node as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates a blob, anchor, clicks it, and revokes the URL', () => { + downloadFile('hello world', 'test.txt', 'text/plain'); + + expect(createObjectURLSpy).toHaveBeenCalledOnce(); + const blob = createObjectURLSpy.mock.calls[0][0] as Blob; + expect(blob.type).toBe('text/plain'); + + const anchor = appendChildSpy.mock.calls[0][0] as HTMLAnchorElement; + expect(anchor.tagName).toBe('A'); + expect(anchor.href).toBe('blob:mock-url'); + expect(anchor.download).toBe('test.txt'); + expect(clickSpy).toHaveBeenCalledOnce(); + expect(removeChildSpy).toHaveBeenCalledOnce(); + expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url'); + }); + + it('uses text/plain as the default mime type', () => { + downloadFile('content', 'default.txt'); + const blob = createObjectURLSpy.mock.calls[0][0] as Blob; + expect(blob.type).toBe('text/plain'); + }); + + it('uses the provided mime type when given', () => { + downloadFile('', 'data.xml', 'application/xml'); + const blob = createObjectURLSpy.mock.calls[0][0] as Blob; + expect(blob.type).toBe('application/xml'); + }); + + it('puts the content into the blob', async () => { + downloadFile('payload', 'test.txt'); + const blob = createObjectURLSpy.mock.calls[0][0] as Blob; + const text = await blob.text(); + expect(text).toBe('payload'); + }); +}); + +describe('groupComments edge cases', () => { + function makeIndented(overrides: { + id?: string; + parentId?: string; + comment?: string; + timecode?: string; + drawing?: string; + usernameIfnull?: string; + } = {}) { + return { + indent: overrides.parentId ? 1 : 0, + comment: { + id: overrides.id || 'c1', + mediaFileId: 'm1', + usernameIfnull: overrides.usernameIfnull, + comment: overrides.comment, + timecode: overrides.timecode, + parentId: overrides.parentId, + drawing: overrides.drawing, + }, + }; + } + + it('returns an empty array when there are no comments', () => { + expect(groupComments([])).toEqual([]); + }); + + it('uses userId when usernameIfnull is missing', () => { + const result = groupComments([ + makeIndented({ id: 'c1', usernameIfnull: undefined as any, comment: 'Hi' }), + ]); + expect(result[0].username).toBeUndefined(); + }); +}); diff --git a/client/src/__tests__/setup.ts b/client/src/__tests__/setup.ts index cd328513..c9ffd2ee 100644 --- a/client/src/__tests__/setup.ts +++ b/client/src/__tests__/setup.ts @@ -61,6 +61,23 @@ afterEach(() => { cleanup(); }) +// Mock localStorage for i18n and other browser-dependent tests +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { store[key] = value; }), + removeItem: vi.fn((key: string) => { delete store[key]; }), + clear: vi.fn(() => { store = {}; }), + }; +})(); + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + writable: true, + configurable: true, +}); + // Mock WebSocket globally global.WebSocket = class MockWebSocket { static CONNECTING = 0; diff --git a/client/src/__tests__/timecodeUtils.test.ts b/client/src/__tests__/timecodeUtils.test.ts new file mode 100644 index 00000000..6a654d1c --- /dev/null +++ b/client/src/__tests__/timecodeUtils.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { timecodeToSeconds, timecodeToSecondsOrNull } from '@/lib/timecodeUtils'; + +describe('timecodeToSeconds', () => { + it('parses HH:MM:SS.mmm format', () => { + expect(timecodeToSeconds('00:00:00.000')).toBe(0); + expect(timecodeToSeconds('00:01:30.500')).toBeCloseTo(90.5); + expect(timecodeToSeconds('01:00:00.000')).toBe(3600); + expect(timecodeToSeconds('12:34:56.789')).toBeCloseTo(12 * 3600 + 34 * 60 + 56.789); + }); + + it('parses HH:MM:SS:FF format assuming 25fps', () => { + expect(timecodeToSeconds('00:00:00:00')).toBe(0); + expect(timecodeToSeconds('00:01:30:00')).toBe(90); + expect(timecodeToSeconds('00:00:01:12')).toBeCloseTo(1 + 12 / 25); + expect(timecodeToSeconds('01:00:00:25')).toBe(3601); + }); + + it('returns 0 for empty or falsy input', () => { + expect(timecodeToSeconds('')).toBe(0); + // @ts-expect-error testing undefined handling + expect(timecodeToSeconds(undefined)).toBe(0); + // @ts-expect-error testing null handling + expect(timecodeToSeconds(null)).toBe(0); + }); + + it('returns 0 for unparseable strings', () => { + expect(timecodeToSeconds('invalid')).toBe(0); + expect(timecodeToSeconds('30.5s')).toBe(0); + expect(timecodeToSeconds('00:01:30')).toBe(0); + expect(timecodeToSeconds('00:01:30:FF')).toBe(0); + }); +}); + +describe('timecodeToSecondsOrNull', () => { + it('returns null for undefined/empty/null', () => { + expect(timecodeToSecondsOrNull(undefined)).toBeNull(); + expect(timecodeToSecondsOrNull('')).toBeNull(); + // @ts-expect-error testing null handling + expect(timecodeToSecondsOrNull(null)).toBeNull(); + }); + + it('returns 0 only for genuine 00:00:00.000 timecodes', () => { + expect(timecodeToSecondsOrNull('00:00:00.000')).toBe(0); + expect(timecodeToSecondsOrNull('00:00:00:00')).toBe(0); + }); + + it('returns null for unparseable strings', () => { + expect(timecodeToSecondsOrNull('invalid')).toBeNull(); + expect(timecodeToSecondsOrNull('30.5s')).toBeNull(); + expect(timecodeToSecondsOrNull('00:01:30')).toBeNull(); + }); + + it('returns seconds for valid timecodes', () => { + expect(timecodeToSecondsOrNull('00:01:30.500')).toBeCloseTo(90.5); + expect(timecodeToSecondsOrNull('00:01:30:00')).toBe(90); + expect(timecodeToSecondsOrNull('01:00:00.000')).toBe(3600); + }); +}); diff --git a/client/src/i18n.ts b/client/src/i18n.ts new file mode 100644 index 00000000..b10f274d --- /dev/null +++ b/client/src/i18n.ts @@ -0,0 +1,2 @@ +export * from './i18n/index'; +export { default } from './i18n/index'; diff --git a/client/src/i18n/locales/fi.json b/client/src/i18n/locales/fi.json index afc98591..29f78ae5 100644 --- a/client/src/i18n/locales/fi.json +++ b/client/src/i18n/locales/fi.json @@ -1 +1 @@ -{"Internal error in authz: {desc}":"Sisäinen virhe käyttöoikeuksien tarkistuksessa: {desc}","Permission denied":"Ei käyttöoikeutta","Permission denied: {desc}":"Ei käyttöoikeutta: {desc}","No such media file.":"Mediatiedostoa ei löydy.","You have no media yet.":"Ei vielä yhtään tiedostoa.","All your media files":"Kaikki mediatiedostosi","Your Media":"Oma media","{kind} deleted.":"{kind} poistettu.","{kind} deleted, but cleanup had errors.":"{kind} poistettu, mutta siivouksessa tapahtui virheitä.","Invalid file name (must contain a letter or number)":"Virheellinen tiedostonimi (täytyy sisältää kirjain tai numero)","Name too long (max 160)":"Nimi on liian pitkä (enintään 160)","{kind} renamed.":"{kind} nimettiin uudelleen.","New name: '{name}'":"Uusi nimi: '{name}'","Failed to edit comment.":"Kommentin muokkaaminen epäonnistui.","No such comment. Cannot edit.":"Kommenttia ei löydy. Ei voida muokata.","Failed to delete comment.":"Kommentin poistaminen epäonnistui.","You can only delete your own comments":"Voit poistaa vain omia kommenttejasi","Comment has replies. Cannot delete.":"Kommenttiin on vastauksia. Ei voida poistaa.","No such comment. Cannot delete.":"Kommenttia ei löydy. Ei voida poistaa.","Failed to add subtitle.":"Tekstityksen lisääminen epäonnistui.","Subtitle file already exists: '{filename}'":"Tekstitystiedosto on jo olemassa: '{filename}'","WebVTT file already exists: '{filename}'":"WebVTT-tiedosto on jo olemassa: '{filename}'","Failed to join collab session: {error}":"Yhteisistuntoon liittyminen epäonnistui: {error}","Report rejected: no active collab session.":"Raportti hylätty: ei aktiivista yhteisistuntoa.","No organizer session.":"Ei organizer-istuntoa.","Missing command from client: {request}":"Asiakkaalta puuttuu komento: {request}","Cmd '{cmd}' failed: {error}":"Komento '{cmd}' epäonnistui: {error}","Rename item":"Nimeä kohde uudelleen","Unknown item type in rename action. Please report this bug.":"Tuntematon kohdetyyppi nimeämistoiminnossa. Ilmoita tästä virheestä.","Rename":"Nimeä uudelleen","Rename selected items":"Nimeä valitut kohteet uudelleen","Are you sure you want to trash this item?":"Haluatko varmasti siirtää tämän kohteen roskakoriin?","Are you sure you want to trash '{title}'?":"Haluatko varmasti siirtää kohteen '{title}' roskakoriin?","Are you sure you want to trash folder '{title}' and ALL CONTENTS?":"Haluatko varmasti siirtää kansion '{title}' ja KAIKEN SEN SISÄLLÖN roskakoriin?","Are you sure you want to trash ALL selected items?":"Haluatko varmasti siirtää KAIKKI valitut kohteet roskakoriin?","Unknown item type in trash action. Please report this bug.":"Tuntematon kohdetyyppi roskakoritoiminnossa. Ilmoita tästä virheestä.","Trash":"Roskakoriin","Trash selected items":"Siirrä valitut kohteet roskakoriin","Are you sure you want to delete this subtitle?":"Haluatko varmasti poistaa tämän tekstityksen?","Invalid time offset: {offset}":"Virheellinen aikasiirtymä: {offset}","Server version too old (v{version}). Please update server.":"Palvelimen versio on liian vanha (v{version}). Päivitä palvelin.","Client version too old (client v{clientVersion} for server v{serverVersion}). Please update client.":"Client-versio on liian vanha (v{clientVersion} vs. palvelimen v{serverVersion}). Päivitä versio.","Bad video open request. See log.":"Virheellinen videon avauspyyntö. Katso loki.","Connecting server...":"Yhdistetään palvelimeen...","View connection errors":"Näytä yhteysvirheet","See browser JS console for more details.":"Lisätietoja selaimen JS-konsolissa.","Sort: date posted":"Järjestys: lisäyspäivä","Sort: timecode":"Järjestys: aikakoodi","Subtitles":"Tekstitykset","collab\u0004Collaborative viewing session active.":"Yhteiskatseluistunto aktiivinen.","collab\u0004Session ID is {id}":"Istunnon tunnus on {id}","collab\u0004Actions like seek, play and draw are mirrored to all participants.":"Toiminnot kuten kelaus, toisto ja piirtäminen peilataan kaikille osallistujille.","collab\u0004To invite people, copy browser URL and send it to them.":"Kutsuaksesi muita, kopioi selaimen osoite ja lähetä se osallistujille.","collab\u0004Exit by clicking the green icon in header.":"Poistu napsauttamalla vihreää kuvaketta ylärivillä.","collab\u0004Understood":"Selvä","Drop video, audio and image files here to upload":"Pudota video-, ääni- ja kuvatiedostoja tähän ladataksesi","Latest messages":"Viimeisimmät viestit","Basic auth logout failed.\nStatus code from {url}: {status} (not 401)":"Basic auth -uloskirjautuminen epäonnistui.\nTilakoodi osoitteesta {url}: {status} (ei 401)","Clapshot Client version {version}\n\nVisit the project page at:\nhttps://github.com/elonen/clapshot\n":"Clapshot client versio {version}\n\nProjektin verkkosivut:\nhttps://github.com/elonen/clapshot\n","Link copied to clipboard.\nSend it to reviewers who have user accounts here.":"Linkki kopioitiin leikepöydälle.\nLähetä se katsojille, joilla on käyttäjätili järjestelmässä.","Share to logged in users":"Jaa kirjautuneille käyttäjille","Download original":"Lataa alkuperäinen","collab\u0004Leave collaborative Session":"Poistu yhteisistunnosta","collab\u0004Collaboration is disabled here":"Yhteisistunto ei käytettävissä","collab\u0004Start Collaborative Session":"Aloita yhteisistunto","Experimental tools":"Kokeelliset työkalut","Import EDL as Comments":"Tuo EDL kommentteina","Export Comments":"Vie kommentit","menu\u0004Language":"Kieli","Logout":"Kirjaudu ulos","menu\u0004About":"Tietoja","Reload page to log in again.":"Lataa sivu uudelleen kirjautuaksesi.","{percent}% uploaded... please wait":"{percent}% ladattu... odota hetki","Upload Failed":"Lataus epäonnistui","Upload Aborted":"Lataus keskeytetty","Uploading: {filename}...":"Ladataan: {filename}...","Drop rejected. Only video, audio and image files are allowed.":"Tiedostotyyppiä ei tueta. Sallitut: video, kuva ja ääni.","This target accepts media — folders can’t be dropped here.":"Tämä kohde hyväksyy vain mediaa — ei kansioita.","Delete comment?":"Poista kommentti?","Link copied to clipboard":"Linkki kopioitu leikepöydälle","Copy link":"Kopioi linkki","(edited)":"(muokattu)","comment\u0004Reply":"Vastaa","comment\u0004Edit":"Muokkaa","comment\u0004Del":"Poista","Your reply...":"Vastauksesi...","comment\u0004Undo":"Kumoa","comment\u0004Redo":"Tee uudelleen","Add a comment - at current time...":"Lisää kommentti (nykyiseen kohtaan)...","Add a comment...":"Lisää kommentti...","Comment is time specific?":"Kommentti on aikakohtainen?","Draw on video":"Piirrä videon päälle","comment\u0004Send":"Lähetä","Edit subtitle":"Muokkaa tekstitystä","subtitle\u0004Title":"Otsikko","Language code":"Kielikoodi","Time offset (sec)":"Aikasiirtymä (sek)","Default Subtitle":"Oletustekstitys","subtitle\u0004Save":"Tallenna","subtitle\u0004Download":"Lataa","subtitle\u0004Del":"Poista","Seek failed to: {target}":"Siirtyminen kohtaan {target} epäonnistui","Video loading not done? Cannot enable drawing.":"Videon lataus kesken? Piirtäminen ei käytössä.","Failed to show image.":"Kuvan näyttäminen epäonnistui.","No time spans found when parsing EDL.":"EDL:ää luettaessa ei löytynyt aikavälejä.","Parsed spans":"Luetut aikavälit","Export format":"Vientimuoto","Frame rate":"Kuvataajuus (FPS)","Format options":"Muotoasetukset","No comments to export.":"Ei vietäviä kommentteja.","Multiple root folders in DB. Please report to support.":"Tietokannassa on useita juurikansioita. Ilmoita tästä tukeen.","Users should have one root folder. This inconsistency was fixed, but your home view might contain unexpected folders.":"Käyttäjillä tulisi olla yksi juurikansio. Tämä virhe korjattiin, mutta kotinäkymässäsi voi olla odottamattomia kansioita.","Home of '{name}'":"Käyttäjän '{name}' koti","Destination folder not found":"Kohdekansiota ei löydy","A version set can only contain media files":"Versiosarja voi sisältää vain mediatiedostoja","Folder id '{id}' not found":"Ei kansiota tunnisteella '{id}'","Cannot move a folder into itself":"Kansiota ei voi siirtää itsensä sisään","Cannot move another user's folder":"Et voi siirtää toisen käyttäjän kansiota","Folder with ID '{id}' is a root folder? Cannot move.":"Kansio tunnisteella '{id}' on juurikansio? Ei siirrettävissä.","Media file '{id}' not found":"Mediatiedostoa '{id}' ei löydy","Cannot move another user's media file":"Et voi siirtää toisen käyttäjän tiedostoja","Parent folder {id} not found":"Yläkansiota {id} ei löydy","Cannot reorder items in another user's folder":"Et voi järjestellä toisen käyttäjän tiedostoja","No folder ID in UI listing, cannot reorder":"UI-listauksessa ei kansiotunnistetta, järjestely ei mahdollista","New folder":"Uusi kansio","Create a new folder":"Luo uusi kansio","Name for the new folder":"Uuden kansion nimi","Move to parent":"Siirrä yläkansioon","Move item to parent folder":"Siirrä kohde yläkansioon","Share folder":"Jaa kansio","Create a shareable link to this folder":"Luo jaettava linkki tähän kansioon","No folder selected to share":"Ei valittua jaettavaa kansiota","Stop sharing":"Lopeta jakaminen","Revoke the shared link for this folder":"Peruuta tämän kansion jakolinkki","No folder selected to unshare":"Ei valittua kansiota, jonka jako peruutetaan","Are you sure you want to revoke the shared link for this folder?":"Haluatko varmasti peruuttaa tämän kansion jakolinkin?","Copy URL":"Kopioi linkki","Copy the shared link to clipboard":"Kopioi jakolinkki leikepöydälle","No shared link available for this folder":"Tälle kansiolle ei ole saatavilla jakolinkkiä","Shared link copied to clipboard!\n\nNOTE: Sharing a folder reveals direct links to all files currently in it, effectively giving recipient PERMANENT access to them, even if remove the folder share later.":"Jakolinkki kopioitiin leikepöydälle!\n\nHUOM: Kansion jakaminen paljastaa suorat linkit kaikkiin siinä tällä hetkellä oleviin tiedostoihin ja antaa vastaanottajalle käytännössä PYSYVÄN pääsyn niihin, vaikka peruuttaisit kansion jaon myöhemmin.","Copy this shared link:":"Kopioi tämä jakolinkki:","Into Version Set":"Muunna versiosarjaksi","Turn this folder into a version set (revisions of one media file)":"Muuta tämä kansio versiosarjaksi (yhden mediatiedoston versiot)","No folder selected":"Ei valittua kansiota","Into Normal Folder":"Muunna tavalliseksi kansioksi","Turn this version set back into a normal folder":"Muuta tämä versiosarja tavalliseksi kansioksi","Make Versioned":"Ryhmittele versiosarjaksi","Group the selected media files into a new version set":"Ryhmittele valitut mediatiedostot uudeksi versiosarjaksi","No media files selected":"Ei valittuja mediatiedostoja","Manage Versions":"Hallitse versioita","Open the version set to manage its versions":"Avaa versiosarja hallitaksesi sen versioita","No version set selected":"Ei valittua versiosarjaa","Set Active Version":"Aseta aktiiviseksi versioksi","Make this version the active one for the set":"Aseta tämä versio sarjan aktiiviseksi versioksi","Cannot set active version (missing ids)":"Aktiivista versiota ei voi asettaa (tunnisteet puuttuvat)","Delete user":"Poista käyttäjä","Delete user if they have no content (only empty root folder)":"Poista käyttäjä, jos hänellä ei ole sisältöä (vain tyhjä juurikansio)","No user folder selected for cleanup":"Ei valittua käyttäjäkansiota siivottavaksi","This will delete the user if they have no media files and only an empty root folder.\n\nComments from this user will be preserved but marked as from a deleted user.\n\nAre you sure?":"Tämä poistaa käyttäjän, jos hänellä ei ole mediatiedostoja ja vain tyhjä juurikansio.\n\nTämän käyttäjän kommentit säilytetään, mutta merkitään poistetun käyttäjän kommenteiksi.\n\nOletko varma?","Cannot fetch contents of another user's folder":"Toisen käyttäjän kansion sisältöä ei voi hakea","Folder ID '{folder_id}' not found":"Kansiota tunnisteella '{folder_id}' ei löydy","Cannot trash another user's folder":"Toisen käyttäjän kansiota ei voi poistaa roskakoriin","folder\u0004UNNAMED":"NIMETÖN","v{num} — {title}":"v{num} — {title}","folder\u0004Version":"Versio","Switch version":"Vaihda versio","Cannot create folder in another user's folder":"Et voi luoda kansiota toisen käyttäjän kansioon","Folder name too long":"Kansion nimi on liian pitkä","Folder name cannot be empty":"Kansion nimi ei voi olla tyhjä","Item with this name already exists in the this folder":"Tämän niminen kohde on jo samassa kansiossa","Only the folder owner can create shares":"Vain kansion omistaja voi luoda jakoja","Only the folder owner can revoke sharing":"Vain kansion omistaja voi peruuttaa jaon","

ADMIN – User Folders

":"

YLLÄPITÄJÄ – Käyttäjäkansiot

","

The following users currently have a home folder and/or media files.
Uploading files or moving items to these folders will transfer ownership to that user.
Trashing a user's home folder will delete everything they have.

":"

Seuraavilla käyttäjillä on tällä hetkellä kotikansio ja/tai mediatiedostoja.
Tiedostojen lähettäminen tai kohteiden siirtäminen näihin kansioihin siirtää omistajuuden kyseiselle käyttäjälle.
Käyttäjän kotikansion poistaminen roskakoriin poistaa kaiken, mitä hänellä on.

","This will delete ALL users who have no media files and only empty root folders.\n\nComments from deleted users will be preserved but marked as from deleted users.\n\nAre you sure?":"Tämä poistaa KAIKKI käyttäjät, joilla ei ole mediatiedostoja ja vain tyhjät juurikansiot.\n\nPoistettujen käyttäjien kommentit säilytetään, mutta merkitään poistettujen käyttäjien kommenteiksi.\n\nOletko varma?","Delete all users without media":"Poista kaikki käyttäjät, joilla ei ole mediaa","

Use the drop zone to upload media files, or right-click on the empty space above to create a folder.

":"

Käytä pudotusaluetta mediatiedostojen lähettämiseen tai napsauta hiiren oikealla painikkeella yllä olevaa tyhjää tilaa luodaksesi kansion.

","

After that, drag items to reorder, or drop them into folders. Hold shift to multi-select.

":"

Tämän jälkeen voit järjestää uudelleen raahaamalla kohteita tai pudottaa ne kansioihin. Valitse useita pitämällä vaihtonäppäintä pohjassa.

","Root folder":"Juurikansio","folder\u0004Home":"Koti","Command argument '{key}' must be an integer":"Komennon argumentin '{key}' täytyy olla kokonaisluku","This shared folder link is invalid or has been revoked":"Tämä jaetun kansion linkki on virheellinen tai se on peruutettu","new_folder command missing 'name' argument":"new_folder-komennosta puuttuu 'name'-argumentti","set_folder_kind requires 'id' and 'kind'":"set_folder_kind vaatii 'id'- ja 'kind'-argumentit","Invalid folder kind: {kind}":"Virheellinen kansiotyyppi: {kind}","Folder ID '{id}' not found":"Kansiota tunnisteella '{id}' ei löydy","Cannot change another user's folder":"Et voi muuttaa toisen käyttäjän kansiota","Cannot turn the root folder into a version set":"Juurikansiota ei voi muuttaa versiosarjaksi","Cannot make a version set from a folder that contains subfolders":"Versiosarjaa ei voi tehdä kansiosta, joka sisältää alikansioita","A version set needs at least one media file":"Versiosarja tarvitsee vähintään yhden mediatiedoston","Make Versioned works on media files only; deselect folders":"Versioitavaksi muuttaminen toimii vain mediatiedostoilla; poista kansioiden valinta","Select at least one media file to make versioned":"Valitse vähintään yksi mediatiedosto versioitavaksi","Selected media file is not in any folder":"Valittu mediatiedosto ei ole missään kansiossa","Cannot modify another user's folder":"Toisen käyttäjän kansiota ei voi muokata","All selected media files must be in the same folder to group them into a version set":"Kaikkien valittujen mediatiedostojen täytyy olla samassa kansiossa, jotta ne voidaan ryhmitellä versiosarjaksi","Version Set":"Versiosarja","set_active_version requires 'folder_id' and 'media_file_id'":"set_active_version vaatii 'folder_id'- ja 'media_file_id'-argumentit","Cannot change another user's version set":"Et voi muuttaa toisen käyttäjän versiosarjaa","That media file is not in this version set":"Kyseinen mediatiedosto ei ole tässä versiosarjassa","rename_folder command missing 'id' or 'new_name' argument":"rename_folder-komennosta puuttuu 'id'- tai 'new_name'-argumentti","trash_folder command missing 'id' argument":"trash_folder-komennosta puuttuu 'id'-argumentti","share_folder command missing 'id' argument":"share_folder-komennosta puuttuu 'id'-argumentti","Server URL base not configured - cannot generate shareable URLs":"Palvelimen URL-perusosoitetta ei ole määritetty - jaettavia osoitteita ei voi luoda","Folder shared. Use popup/'Copy URL' to get a link.":"Kansio jaettu. Hae linkki ponnahdusikkunasta tai 'Kopioi linkki' -toiminnolla.","Folder sharing token created for '{title}'.":"Kansion jakotunnus luotiin kohteelle '{title}'.","revoke_share command missing 'id' argument":"revoke_share-komennosta puuttuu 'id'-argumentti","Folder sharing has been revoked":"Kansion jako on peruutettu","This folder is not currently shared":"Tätä kansiota ei ole tällä hetkellä jaettu","cleanup_empty_user command missing 'folder_id' argument":"cleanup_empty_user-komennosta puuttuu 'folder_id'-argumentti","Only admin can clean up users":"Vain ylläpitäjä voi siivota käyttäjiä","Cleaned up {count} empty user":"Siivottiin {count} tyhjää käyttäjää","Comments from deleted users are preserved but marked as from deleted users.":"Poistettujen käyttäjien kommentit säilytetään, mutta merkitään poistettujen käyttäjien kommenteiksi.","No empty users found to clean up":"Siivottavia tyhjiä käyttäjiä ei löytynyt","User '{id}' has been cleaned up (deleted)":"Käyttäjä '{id}' on siivottu (poistettu)","Comments from this user are preserved but marked as from a deleted user.":"Tämän käyttäjän kommentit säilytetään, mutta merkitään poistetun käyttäjän kommenteiksi.","User '{id}' was not deleted":"Käyttäjää '{id}' ei poistettu","User still has media files or non-empty folders.":"Käyttäjällä on edelleen mediatiedostoja tai ei-tyhjiä kansioita.","Unknown organizer command: {cmd}":"Tuntematon organizer-komento: {cmd}"} +{"Internal error in authz: {desc}":"Sisäinen virhe käyttöoikeuksien tarkistuksessa: {desc}","Permission denied":"Ei käyttöoikeutta","Permission denied: {desc}":"Ei käyttöoikeutta: {desc}","No such media file.":"Mediatiedostoa ei löydy.","You have no media yet.":"Ei vielä yhtään tiedostoa.","All your media files":"Kaikki mediatiedostosi","Your Media":"Oma media","{kind} deleted.":"{kind} poistettu.","{kind} deleted, but cleanup had errors.":"{kind} poistettu, mutta siivouksessa tapahtui virheitä.","Invalid file name (must contain a letter or number)":"Virheellinen tiedostonimi (täytyy sisältää kirjain tai numero)","Name too long (max 160)":"Nimi on liian pitkä (enintään 160)","{kind} renamed.":"{kind} nimettiin uudelleen.","New name: '{name}'":"Uusi nimi: '{name}'","Failed to edit comment.":"Kommentin muokkaaminen epäonnistui.","No such comment. Cannot edit.":"Kommenttia ei löydy. Ei voida muokata.","Failed to delete comment.":"Kommentin poistaminen epäonnistui.","You can only delete your own comments":"Voit poistaa vain omia kommenttejasi","Comment has replies. Cannot delete.":"Kommenttiin on vastauksia. Ei voida poistaa.","No such comment. Cannot delete.":"Kommenttia ei löydy. Ei voida poistaa.","Failed to add subtitle.":"Tekstityksen lisääminen epäonnistui.","Subtitle file already exists: '{filename}'":"Tekstitystiedosto on jo olemassa: '{filename}'","WebVTT file already exists: '{filename}'":"WebVTT-tiedosto on jo olemassa: '{filename}'","Failed to join collab session: {error}":"Yhteisistuntoon liittyminen epäonnistui: {error}","Report rejected: no active collab session.":"Raportti hylätty: ei aktiivista yhteisistuntoa.","No organizer session.":"Ei organizer-istuntoa.","Missing command from client: {request}":"Asiakkaalta puuttuu komento: {request}","Cmd '{cmd}' failed: {error}":"Komento '{cmd}' epäonnistui: {error}","Rename item":"Nimeä kohde uudelleen","Unknown item type in rename action. Please report this bug.":"Tuntematon kohdetyyppi nimeämistoiminnossa. Ilmoita tästä virheestä.","Rename":"Nimeä uudelleen","Rename selected items":"Nimeä valitut kohteet uudelleen","Are you sure you want to trash this item?":"Haluatko varmasti siirtää tämän kohteen roskakoriin?","Are you sure you want to trash '{title}'?":"Haluatko varmasti siirtää kohteen '{title}' roskakoriin?","Are you sure you want to trash folder '{title}' and ALL CONTENTS?":"Haluatko varmasti siirtää kansion '{title}' ja KAIKEN SEN SISÄLLÖN roskakoriin?","Are you sure you want to trash ALL selected items?":"Haluatko varmasti siirtää KAIKKI valitut kohteet roskakoriin?","Unknown item type in trash action. Please report this bug.":"Tuntematon kohdetyyppi roskakoritoiminnossa. Ilmoita tästä virheestä.","Trash":"Roskakoriin","Trash selected items":"Siirrä valitut kohteet roskakoriin","Comment not found":"Kommenttia ei löytynyt","Subtitle not found. See log.":"Tekstitystä ei löytynyt. Katso loki.","Error adding subtitle file. See log.":"Virhe lisättäessä tekstitystiedostoa. Katso loki.","Are you sure you want to delete this subtitle?":"Haluatko varmasti poistaa tämän tekstityksen?","Invalid time offset: {offset}":"Virheellinen aikasiirtymä: {offset}","Unknown URL parameter: '{key}'":"Tuntematon URL-parametri: '{key}'","Client error: {error}":"Client-virhe: {error}","Server version too old (v{version}). Please update server.":"Palvelimen versio on liian vanha (v{version}). Päivitä palvelin.","Client version too old (client v{clientVersion} for server v{serverVersion}). Please update client.":"Client-versio on liian vanha (v{clientVersion} vs. palvelimen v{serverVersion}). Päivitä versio.","No user in welcome message":"Ei käyttäjää tervetuloviestissä","Clapshot - {title}":"Clapshot - {title}","Home":"Koti","Bad video open request. See log.":"Virheellinen videon avauspyyntö. Katso loki.","{user} is controlling":"{user} ohjaa","No open action for item. See log.":"Kohteelle ei ole avaus-toimintoa. Katso loki.","BUG: Unsupported script lang. See log.":"BUG: Ei-tuettu skriptikieli. Katso loki.","Organizer script error. See log.":"Organizer-skriptivirhe. Katso loki.","Connecting server...":"Yhdistetään palvelimeen...","View connection errors":"Näytä yhteysvirheet","See browser JS console for more details.":"Lisätietoja selaimen JS-konsolissa.","Sort: date posted":"Järjestys: lisäyspäivä","Sort: timecode":"Järjestys: aikakoodi","Subtitles":"Tekstitykset","Upload subtitles":"Lataa tekstitys","collab\u0004Collaborative viewing session active.":"Yhteiskatseluistunto aktiivinen.","collab\u0004Session ID is {id}":"Istunnon tunnus on {id}","collab\u0004Actions like seek, play and draw are mirrored to all participants.":"Toiminnot kuten kelaus, toisto ja piirtäminen peilataan kaikille osallistujille.","collab\u0004To invite people, copy browser URL and send it to them.":"Kutsuaksesi muita, kopioi selaimen osoite ja lähetä se osallistujille.","collab\u0004Exit by clicking the green icon in header.":"Poistu napsauttamalla vihreää kuvaketta ylärivillä.","collab\u0004Understood":"Selvä","Drop video, audio and image files here to upload":"Pudota video-, ääni- ja kuvatiedostoja tähän ladataksesi","Latest messages":"Viimeisimmät viestit","Basic auth logout failed.\nStatus code from {url}: {status} (not 401)":"Basic auth -uloskirjautuminen epäonnistui.\nTilakoodi osoitteesta {url}: {status} (ei 401)","Clapshot Client version {version}\n\nVisit the project page at:\nhttps://github.com/elonen/clapshot\n":"Clapshot client versio {version}\n\nProjektin verkkosivut:\nhttps://github.com/elonen/clapshot\n","Link copied to clipboard.\nSend it to reviewers who have user accounts here.":"Linkki kopioitiin leikepöydälle.\nLähetä se katsojille, joilla on käyttäjätili järjestelmässä.","Open menu":"Avaa valikko","Share to logged in users":"Jaa kirjautuneille käyttäjille","Download original":"Lataa alkuperäinen","collab\u0004Leave collaborative Session":"Poistu yhteisistunnosta","collab\u0004Collaboration is disabled here":"Yhteisistunto ei käytettävissä","collab\u0004Start Collaborative Session":"Aloita yhteisistunto","Experimental tools":"Kokeelliset työkalut","Import EDL as Comments":"Tuo EDL kommentteina","Export Comments":"Vie kommentit","Processing...":"Käsitellään...","menu\u0004Language":"Kieli","Logout":"Kirjaudu ulos","menu\u0004About":"Tietoja","Reload page to log in again.":"Lataa sivu uudelleen kirjautuaksesi.","OK":"OK","ERROR":"VIRHE","PROGRESS":"EDISTYMINEN","{percent}% uploaded... please wait":"{percent}% ladattu... odota hetki","Upload Failed":"Lataus epäonnistui","Upload Aborted":"Lataus keskeytetty","Uploading: {filename}...":"Ladataan: {filename}...","Drop rejected. Only video, audio and image files are allowed.":"Tiedostotyyppiä ei tueta. Sallitut: video, kuva ja ääni.","UI BUG: item not found":"UI-BUG: kohdetta ei löytynyt","UI BUG: item not found or missing openAction":"UI-BUG: kohdetta ei löytynyt tai siltä puuttuu avaus-toiminto","UI BUG: popup container missing":"UI-BUG: ponnahdusvalikon säiliö puuttuu","UI BUG: popup container child missing hide()":"UI-BUG: ponnahdusvalikon lapselta puuttuu hide()","Unknown item type":"Tuntematon kohdetyyppi","This target accepts media — folders can’t be dropped here.":"Tämä kohde hyväksyy vain mediaa — ei kansioita.","(no date)":"(ei päivämäärää)","video icon":"video-ikoni","Delete comment?":"Poista kommentti?","Link copied to clipboard":"Linkki kopioitu leikepöydälle","Copy link":"Kopioi linkki","(edited)":"(muokattu)","comment\u0004Reply":"Vastaa","comment\u0004Edit":"Muokkaa","comment\u0004Del":"Poista","Your reply...":"Vastauksesi...","comment\u0004Undo":"Kumoa","comment\u0004Redo":"Tee uudelleen","Select {color} color":"Valitse {color} väri","Add a comment - at current time...":"Lisää kommentti (nykyiseen kohtaan)...","Add a comment...":"Lisää kommentti...","Comment is time specific?":"Kommentti on aikakohtainen?","Draw on video":"Piirrä videon päälle","comment\u0004Send":"Lähetä","Edit subtitle":"Muokkaa tekstitystä","subtitle\u0004Title":"Otsikko","Language code":"Kielikoodi","ISO 639 language codes information":"ISO 639 -kielikoodien tiedot","Time offset (sec)":"Aikasiirtymä (sek)","Default Subtitle":"Oletustekstitys","subtitle\u0004Save":"Tallenna","subtitle\u0004Download":"Lataa","subtitle\u0004Del":"Poista","Seek failed to: {target}":"Siirtyminen kohtaan {target} epäonnistui","Video loading not done? Cannot enable drawing.":"Videon lataus kesken? Piirtäminen ei käytössä.","Failed to show image.":"Kuvan näyttäminen epäonnistui.","Step backwards":"Kelaa taaksepäin","Play/Pause":"Toista/Pysäytä","Step forwards":"Kelaa eteenpäin","frame label\u0004FR":"KU","Set loop start to current frame":"Aseta silmukan alku nykyiseen kehykseen","Set loop end to current frame":"Aseta silmukan loppu nykyiseen kehykseen","Toggle closed captioning":"Vaihda tekstitystä päälle/pois","Mute audio":"Mykistä ääni","Unmute audio":"Palauta ääni","Toggle fullscreen (f)":"Vaihda koko näyttöön (f)","Toggle fullscreen":"Vaihda koko näyttöön","No time spans found when parsing EDL.":"EDL:ää luettaessa ei löytynyt aikavälejä.","Upload EDL":"Lataa EDL","Frame rate":"Kuvataajuus (FPS)","Add as comments":"Lisää kommentteina","Cancel":"Peruuta","Parsed spans":"Luetut aikavälit","Export format":"Vientimuoto","Format options":"Muotoasetukset","Export":"Vie","1 comment will be exported.":"1 kommentti viedään.","{n} comments will be exported.":"{n} kommenttia viedään.","No comments to export.":"Ei vietäviä kommentteja.","Multiple root folders in DB. Please report to support.":"Tietokannassa on useita juurikansioita. Ilmoita tästä tukeen.","Users should have one root folder. This inconsistency was fixed, but your home view might contain unexpected folders.":"Käyttäjillä tulisi olla yksi juurikansio. Tämä virhe korjattiin, mutta kotinäkymässäsi voi olla odottamattomia kansioita.","Home of '{name}'":"Käyttäjän '{name}' koti","Destination folder not found":"Kohdekansiota ei löydy","A version set can only contain media files":"Versiosarja voi sisältää vain mediatiedostoja","Folder id '{id}' not found":"Ei kansiota tunnisteella '{id}'","Cannot move a folder into itself":"Kansiota ei voi siirtää itsensä sisään","Cannot move another user's folder":"Et voi siirtää toisen käyttäjän kansiota","Folder with ID '{id}' is a root folder? Cannot move.":"Kansio tunnisteella '{id}' on juurikansio? Ei siirrettävissä.","Media file '{id}' not found":"Mediatiedostoa '{id}' ei löydy","Cannot move another user's media file":"Et voi siirtää toisen käyttäjän tiedostoja","Parent folder {id} not found":"Yläkansiota {id} ei löydy","Cannot reorder items in another user's folder":"Et voi järjestellä toisen käyttäjän tiedostoja","No folder ID in UI listing, cannot reorder":"UI-listauksessa ei kansiotunnistetta, järjestely ei mahdollista","New folder":"Uusi kansio","Create a new folder":"Luo uusi kansio","Name for the new folder":"Uuden kansion nimi","Move to parent":"Siirrä yläkansioon","Move item to parent folder":"Siirrä kohde yläkansioon","Share folder":"Jaa kansio","Create a shareable link to this folder":"Luo jaettava linkki tähän kansioon","No folder selected to share":"Ei valittua jaettavaa kansiota","Stop sharing":"Lopeta jakaminen","Revoke the shared link for this folder":"Peruuta tämän kansion jakolinkki","No folder selected to unshare":"Ei valittua kansiota, jonka jako peruutetaan","Are you sure you want to revoke the shared link for this folder?":"Haluatko varmasti peruuttaa tämän kansion jakolinkin?","Shared link copied to clipboard!\n\nNOTE: Sharing a folder reveals direct links to all files currently in it, effectively giving recipient PERMANENT access to them, even if remove the folder share later.":"Jakolinkki kopioitiin leikepöydälle!\n\nHUOM: Kansion jakaminen paljastaa suorat linkit kaikkiin siinä tällä hetkellä oleviin tiedostoihin ja antaa vastaanottajalle käytännössä PYSYVÄN pääsyn niihin, vaikka peruuttaisit kansion jaon myöhemmin.","Copy URL":"Kopioi linkki","Copy the shared link to clipboard":"Kopioi jakolinkki leikepöydälle","No shared link available for this folder":"Tälle kansiolle ei ole saatavilla jakolinkkiä","Copy this shared link:":"Kopioi tämä jakolinkki:","Into Version Set":"Muunna versiosarjaksi","Turn this folder into a version set (revisions of one media file)":"Muuta tämä kansio versiosarjaksi (yhden mediatiedoston versiot)","No folder selected":"Ei valittua kansiota","Into Normal Folder":"Muunna tavalliseksi kansioksi","Turn this version set back into a normal folder":"Muuta tämä versiosarja tavalliseksi kansioksi","Make Versioned":"Ryhmittele versiosarjaksi","Group the selected media files into a new version set":"Ryhmittele valitut mediatiedostot uudeksi versiosarjaksi","No media files selected":"Ei valittuja mediatiedostoja","Manage Versions":"Hallitse versioita","Open the version set to manage its versions":"Avaa versiosarja hallitaksesi sen versioita","No version set selected":"Ei valittua versiosarjaa","Set Active Version":"Aseta aktiiviseksi versioksi","Make this version the active one for the set":"Aseta tämä versio sarjan aktiiviseksi versioksi","Cannot set active version (missing ids)":"Aktiivista versiota ei voi asettaa (tunnisteet puuttuvat)","This will delete the user if they have no media files and only an empty root folder.\n\nComments from this user will be preserved but marked as from a deleted user.\n\nAre you sure?":"Tämä poistaa käyttäjän, jos hänellä ei ole mediatiedostoja ja vain tyhjä juurikansio.\n\nTämän käyttäjän kommentit säilytetään, mutta merkitään poistetun käyttäjän kommenteiksi.\n\nOletko varma?","Delete user":"Poista käyttäjä","Delete user if they have no content (only empty root folder)":"Poista käyttäjä, jos hänellä ei ole sisältöä (vain tyhjä juurikansio)","No user folder selected for cleanup":"Ei valittua käyttäjäkansiota siivottavaksi","Cannot fetch contents of another user's folder":"Toisen käyttäjän kansion sisältöä ei voi hakea","Folder ID '{folder_id}' not found":"Kansiota tunnisteella '{folder_id}' ei löydy","Cannot trash another user's folder":"Toisen käyttäjän kansiota ei voi poistaa roskakoriin","folder\u0004UNNAMED":"NIMETÖN","v{num} — {title}":"v{num} — {title}","folder\u0004Version":"Versio","Switch version":"Vaihda versio","Cannot create folder in another user's folder":"Et voi luoda kansiota toisen käyttäjän kansioon","Folder name too long":"Kansion nimi on liian pitkä","Folder name cannot be empty":"Kansion nimi ei voi olla tyhjä","Item with this name already exists in the this folder":"Tämän niminen kohde on jo samassa kansiossa","Only the folder owner can create shares":"Vain kansion omistaja voi luoda jakoja","Only the folder owner can revoke sharing":"Vain kansion omistaja voi peruuttaa jaon","

ADMIN – User Folders

":"

YLLÄPITÄJÄ – Käyttäjäkansiot

","

The following users currently have a home folder and/or media files.
Uploading files or moving items to these folders will transfer ownership to that user.
Trashing a user's home folder will delete everything they have.

":"

Seuraavilla käyttäjillä on tällä hetkellä kotikansio ja/tai mediatiedostoja.
Tiedostojen lähettäminen tai kohteiden siirtäminen näihin kansioihin siirtää omistajuuden kyseiselle käyttäjälle.
Käyttäjän kotikansion poistaminen roskakoriin poistaa kaiken, mitä hänellä on.

","This will delete ALL users who have no media files and only empty root folders.\n\nComments from deleted users will be preserved but marked as from deleted users.\n\nAre you sure?":"Tämä poistaa KAIKKI käyttäjät, joilla ei ole mediatiedostoja ja vain tyhjät juurikansiot.\n\nPoistettujen käyttäjien kommentit säilytetään, mutta merkitään poistettujen käyttäjien kommenteiksi.\n\nOletko varma?","Delete all users without media":"Poista kaikki käyttäjät, joilla ei ole mediaa","

Use the drop zone to upload media files, or right-click on the empty space above to create a folder.

":"

Käytä pudotusaluetta mediatiedostojen lähettämiseen tai napsauta hiiren oikealla painikkeella yllä olevaa tyhjää tilaa luodaksesi kansion.

","

After that, drag items to reorder, or drop them into folders. Hold shift to multi-select.

":"

Tämän jälkeen voit järjestää uudelleen raahaamalla kohteita tai pudottaa ne kansioihin. Valitse useita pitämällä vaihtonäppäintä pohjassa.

","Root folder":"Juurikansio","folder\u0004Home":"Koti","Command argument '{key}' must be an integer":"Komennon argumentin '{key}' täytyy olla kokonaisluku","This shared folder link is invalid or has been revoked":"Tämä jaetun kansion linkki on virheellinen tai se on peruutettu","new_folder command missing 'name' argument":"new_folder-komennosta puuttuu 'name'-argumentti","set_folder_kind requires 'id' and 'kind'":"set_folder_kind vaatii 'id'- ja 'kind'-argumentit","Invalid folder kind: {kind}":"Virheellinen kansiotyyppi: {kind}","Folder ID '{id}' not found":"Kansiota tunnisteella '{id}' ei löydy","Cannot change another user's folder":"Et voi muuttaa toisen käyttäjän kansiota","Cannot turn the root folder into a version set":"Juurikansiota ei voi muuttaa versiosarjaksi","Cannot make a version set from a folder that contains subfolders":"Versiosarjaa ei voi tehdä kansiosta, joka sisältää alikansioita","A version set needs at least one media file":"Versiosarja tarvitsee vähintään yhden mediatiedoston","Make Versioned works on media files only; deselect folders":"Versioitavaksi muuttaminen toimii vain mediatiedostoilla; poista kansioiden valinta","Select at least one media file to make versioned":"Valitse vähintään yksi mediatiedosto versioitavaksi","Selected media file is not in any folder":"Valittu mediatiedosto ei ole missään kansiossa","Cannot modify another user's folder":"Toisen käyttäjän kansiota ei voi muokata","All selected media files must be in the same folder to group them into a version set":"Kaikkien valittujen mediatiedostojen täytyy olla samassa kansiossa, jotta ne voidaan ryhmitellä versiosarjaksi","Version Set":"Versiosarja","set_active_version requires 'folder_id' and 'media_file_id'":"set_active_version vaatii 'folder_id'- ja 'media_file_id'-argumentit","Cannot change another user's version set":"Et voi muuttaa toisen käyttäjän versiosarjaa","That media file is not in this version set":"Kyseinen mediatiedosto ei ole tässä versiosarjassa","rename_folder command missing 'id' or 'new_name' argument":"rename_folder-komennosta puuttuu 'id'- tai 'new_name'-argumentti","trash_folder command missing 'id' argument":"trash_folder-komennosta puuttuu 'id'-argumentti","share_folder command missing 'id' argument":"share_folder-komennosta puuttuu 'id'-argumentti","Server URL base not configured - cannot generate shareable URLs":"Palvelimen URL-perusosoitetta ei ole määritetty - jaettavia osoitteita ei voi luoda","Folder shared. Use popup/'Copy URL' to get a link.":"Kansio jaettu. Hae linkki ponnahdusikkunasta tai 'Kopioi linkki' -toiminnolla.","Folder sharing token created for '{title}'.":"Kansion jakotunnus luotiin kohteelle '{title}'.","revoke_share command missing 'id' argument":"revoke_share-komennosta puuttuu 'id'-argumentti","Folder sharing has been revoked":"Kansion jako on peruutettu","This folder is not currently shared":"Tätä kansiota ei ole tällä hetkellä jaettu","cleanup_empty_user command missing 'folder_id' argument":"cleanup_empty_user-komennosta puuttuu 'folder_id'-argumentti","Only admin can clean up users":"Vain ylläpitäjä voi siivota käyttäjiä","Cleaned up {count} empty user":"Siivottiin {count} tyhjää käyttäjää","Comments from deleted users are preserved but marked as from deleted users.":"Poistettujen käyttäjien kommentit säilytetään, mutta merkitään poistettujen käyttäjien kommenteiksi.","No empty users found to clean up":"Siivottavia tyhjiä käyttäjiä ei löytynyt","User '{id}' has been cleaned up (deleted)":"Käyttäjä '{id}' on siivottu (poistettu)","Comments from this user are preserved but marked as from a deleted user.":"Tämän käyttäjän kommentit säilytetään, mutta merkitään poistetun käyttäjän kommenteiksi.","User '{id}' was not deleted":"Käyttäjää '{id}' ei poistettu","User still has media files or non-empty folders.":"Käyttäjällä on edelleen mediatiedostoja tai ei-tyhjiä kansioita.","Unknown organizer command: {cmd}":"Tuntematon organizer-komento: {cmd}"} diff --git a/client/src/i18n/locales/fr.json b/client/src/i18n/locales/fr.json index 25e31f5a..0d9c1986 100644 --- a/client/src/i18n/locales/fr.json +++ b/client/src/i18n/locales/fr.json @@ -1 +1 @@ -{"Internal error in authz: {desc}":"Erreur interne d'autorisation : {desc}","Permission denied":"Permission refusée","Permission denied: {desc}":"Permission refusée : {desc}","No such media file.":"Fichier multimédia introuvable.","You have no media yet.":"Vous n'avez pas encore de média.","All your media files":"Tous vos fichiers multimédias","Your Media":"Vos médias","{kind} deleted.":"{kind} supprimé.","{kind} deleted, but cleanup had errors.":"{kind} supprimé, mais le nettoyage a rencontré des erreurs.","Invalid file name (must contain a letter or number)":"Nom de fichier invalide (doit contenir une lettre ou un chiffre)","Name too long (max 160)":"Nom trop long (160 maximum)","{kind} renamed.":"{kind} renommé.","New name: '{name}'":"Nouveau nom : '{name}'","Failed to edit comment.":"Échec de la modification du commentaire.","No such comment. Cannot edit.":"Commentaire introuvable. Modification impossible.","Failed to delete comment.":"Échec de la suppression du commentaire.","You can only delete your own comments":"Vous ne pouvez supprimer que vos propres commentaires","Comment has replies. Cannot delete.":"Le commentaire a des réponses. Suppression impossible.","No such comment. Cannot delete.":"Commentaire introuvable. Suppression impossible.","Failed to add subtitle.":"Échec de l'ajout du sous-titre.","Subtitle file already exists: '{filename}'":"Le fichier de sous-titres existe déjà : '{filename}'","WebVTT file already exists: '{filename}'":"Le fichier WebVTT existe déjà : '{filename}'","Failed to join collab session: {error}":"Échec de la connexion à la session collaborative : {error}","Report rejected: no active collab session.":"Rapport rejeté : aucune session collaborative active.","No organizer session.":"Aucune session d'organiseur.","Missing command from client: {request}":"Commande manquante du client : {request}","Cmd '{cmd}' failed: {error}":"Échec de la commande '{cmd}' : {error}","Rename item":"Renommer l'élément","Unknown item type in rename action. Please report this bug.":"Type d'élément inconnu dans l'action renommer. Veuillez signaler ce bug.","Rename":"Renommer","Rename selected items":"Renommer les éléments sélectionnés","Are you sure you want to trash this item?":"Voulez-vous vraiment mettre cet élément à la corbeille ?","Are you sure you want to trash '{title}'?":"Voulez-vous vraiment mettre '{title}' à la corbeille ?","Are you sure you want to trash folder '{title}' and ALL CONTENTS?":"Voulez-vous vraiment mettre le dossier '{title}' et TOUT SON CONTENU à la corbeille ?","Are you sure you want to trash ALL selected items?":"Voulez-vous vraiment mettre TOUS les éléments sélectionnés à la corbeille ?","Unknown item type in trash action. Please report this bug.":"Type d'élément inconnu dans l'action corbeille. Veuillez signaler ce bug.","Trash":"Corbeille","Trash selected items":"Mettre les éléments sélectionnés à la corbeille","Are you sure you want to delete this subtitle?":"Voulez-vous vraiment supprimer ce sous-titre ?","Invalid time offset: {offset}":"Décalage temporel invalide : {offset}","Server version too old (v{version}). Please update server.":"Version du serveur trop ancienne (v{version}). Veuillez mettre à jour le serveur.","Client version too old (client v{clientVersion} for server v{serverVersion}). Please update client.":"Version du client trop ancienne (client v{clientVersion} pour le serveur v{serverVersion}). Veuillez mettre à jour le client.","Bad video open request. See log.":"Requête d'ouverture vidéo incorrecte. Consultez le journal.","Connecting server...":"Connexion au serveur...","View connection errors":"Voir les erreurs de connexion","See browser JS console for more details.":"Consultez la console JS du navigateur pour plus de détails.","Sort: date posted":"Tri : date de publication","Sort: timecode":"Tri : timecode","Subtitles":"Sous-titres","collab\u0004Collaborative viewing session active.":"Session de visionnage collaboratif active.","collab\u0004Session ID is {id}":"Identifiant de session : {id}","collab\u0004Actions like seek, play and draw are mirrored to all participants.":"Les actions comme le déplacement, la lecture et le dessin sont synchronisées avec tous les participants.","collab\u0004To invite people, copy browser URL and send it to them.":"Pour inviter des personnes, copiez l'URL du navigateur et envoyez-la leur.","collab\u0004Exit by clicking the green icon in header.":"Quittez en cliquant sur l'icône verte dans l'en-tête.","collab\u0004Understood":"Compris","Drop video, audio and image files here to upload":"Déposez ici des fichiers vidéo, audio et image pour les téléverser","Latest messages":"Derniers messages","Basic auth logout failed.\nStatus code from {url}: {status} (not 401)":"Échec de la déconnexion (authentification de base).\nCode de statut de {url} : {status} (pas 401)","Clapshot Client version {version}\n\nVisit the project page at:\nhttps://github.com/elonen/clapshot\n":"Client Clapshot version {version}\n\nVisitez la page du projet à :\nhttps://github.com/elonen/clapshot\n","Link copied to clipboard.\nSend it to reviewers who have user accounts here.":"Lien copié dans le presse-papiers.\nEnvoyez-le aux relecteurs qui possèdent un compte ici.","Share to logged in users":"Partager avec les utilisateurs connectés","Download original":"Télécharger l'original","collab\u0004Leave collaborative Session":"Quitter la session collaborative","collab\u0004Collaboration is disabled here":"La collaboration est désactivée ici","collab\u0004Start Collaborative Session":"Démarrer une session collaborative","Experimental tools":"Outils expérimentaux","Import EDL as Comments":"Importer EDL comme commentaires","Export Comments":"Exporter les commentaires","menu\u0004Language":"Langue","Logout":"Déconnexion","menu\u0004About":"À propos","Reload page to log in again.":"Rechargez la page pour vous reconnecter.","{percent}% uploaded... please wait":"{percent}% téléversé... veuillez patienter","Upload Failed":"Échec du téléversement","Upload Aborted":"Téléversement annulé","Uploading: {filename}...":"Téléversement : {filename}...","Drop rejected. Only video, audio and image files are allowed.":"Dépôt refusé. Seuls les fichiers vidéo, audio et image sont acceptés.","This target accepts media — folders can’t be dropped here.":"Cette cible accepte des médias — les dossiers ne peuvent pas y être déposés.","Delete comment?":"Supprimer le commentaire ?","Link copied to clipboard":"Lien copié dans le presse-papiers","Copy link":"Copier le lien","(edited)":"(modifié)","comment\u0004Reply":"Répondre","comment\u0004Edit":"Modifier","comment\u0004Del":"Suppr.","Your reply...":"Votre réponse...","comment\u0004Undo":"Annuler","comment\u0004Redo":"Rétablir","Add a comment - at current time...":"Ajouter un commentaire — à l'instant actuel...","Add a comment...":"Ajouter un commentaire...","Comment is time specific?":"Commentaire lié à un instant précis ?","Draw on video":"Dessiner sur la vidéo","comment\u0004Send":"Envoyer","Edit subtitle":"Modifier le sous-titre","subtitle\u0004Title":"Titre","Language code":"Code de langue","Time offset (sec)":"Décalage temporel (sec)","Default Subtitle":"Sous-titre par défaut","subtitle\u0004Save":"Enregistrer","subtitle\u0004Download":"Télécharger","subtitle\u0004Del":"Suppr.","Seek failed to: {target}":"Échec du déplacement vers : {target}","Video loading not done? Cannot enable drawing.":"Chargement de la vidéo non terminé ? Impossible d'activer le dessin.","Failed to show image.":"Échec de l'affichage de l'image.","No time spans found when parsing EDL.":"Aucune plage temporelle trouvée lors de l'analyse de l'EDL.","Parsed spans":"Plages analysées","Export format":"Format d'exportation","Frame rate":"Fréquence d'images","Format options":"Options de format","No comments to export.":"Aucun commentaire à exporter.","Multiple root folders in DB. Please report to support.":"Plusieurs dossiers racine dans la base de données. Veuillez le signaler au support.","Users should have one root folder. This inconsistency was fixed, but your home view might contain unexpected folders.":"Les utilisateurs ne devraient avoir qu'un seul dossier racine. Cette incohérence a été corrigée, mais votre vue d'accueil peut contenir des dossiers inattendus.","Home of '{name}'":"Accueil de '{name}'","Destination folder not found":"Dossier de destination introuvable","A version set can only contain media files":"Un ensemble de versions ne peut contenir que des fichiers multimédias","Folder id '{id}' not found":"Dossier d'identifiant '{id}' introuvable","Cannot move a folder into itself":"Impossible de déplacer un dossier dans lui-même","Cannot move another user's folder":"Impossible de déplacer le dossier d'un autre utilisateur","Folder with ID '{id}' is a root folder? Cannot move.":"Le dossier d'identifiant '{id}' est un dossier racine ? Déplacement impossible.","Media file '{id}' not found":"Fichier multimédia '{id}' introuvable","Cannot move another user's media file":"Impossible de déplacer le fichier multimédia d'un autre utilisateur","Parent folder {id} not found":"Dossier parent {id} introuvable","Cannot reorder items in another user's folder":"Impossible de réorganiser les éléments dans le dossier d'un autre utilisateur","No folder ID in UI listing, cannot reorder":"Aucun identifiant de dossier dans la liste de l'interface, réorganisation impossible","New folder":"Nouveau dossier","Create a new folder":"Créer un nouveau dossier","Name for the new folder":"Nom du nouveau dossier","Move to parent":"Déplacer vers le parent","Move item to parent folder":"Déplacer l'élément vers le dossier parent","Share folder":"Partager le dossier","Create a shareable link to this folder":"Créer un lien de partage vers ce dossier","No folder selected to share":"Aucun dossier sélectionné à partager","Stop sharing":"Arrêter le partage","Revoke the shared link for this folder":"Révoquer le lien partagé de ce dossier","No folder selected to unshare":"Aucun dossier sélectionné pour arrêter le partage","Are you sure you want to revoke the shared link for this folder?":"Voulez-vous vraiment révoquer le lien partagé de ce dossier ?","Copy URL":"Copier l'URL","Copy the shared link to clipboard":"Copier le lien partagé dans le presse-papiers","No shared link available for this folder":"Aucun lien partagé disponible pour ce dossier","Shared link copied to clipboard!\n\nNOTE: Sharing a folder reveals direct links to all files currently in it, effectively giving recipient PERMANENT access to them, even if remove the folder share later.":"Lien partagé copié dans le presse-papiers !\n\nREMARQUE : partager un dossier expose des liens directs vers tous les fichiers qu'il contient actuellement, donnant ainsi au destinataire un accès PERMANENT à ceux-ci, même si vous arrêtez le partage du dossier plus tard.","Copy this shared link:":"Copiez ce lien partagé :","Into Version Set":"En ensemble de versions","Turn this folder into a version set (revisions of one media file)":"Transformer ce dossier en ensemble de versions (révisions d'un même fichier multimédia)","No folder selected":"Aucun dossier sélectionné","Into Normal Folder":"En dossier normal","Turn this version set back into a normal folder":"Retransformer cet ensemble de versions en dossier normal","Make Versioned":"Rendre versionné","Group the selected media files into a new version set":"Regrouper les fichiers multimédias sélectionnés en un nouvel ensemble de versions","No media files selected":"Aucun fichier multimédia sélectionné","Manage Versions":"Gérer les versions","Open the version set to manage its versions":"Ouvrir l'ensemble de versions pour gérer ses versions","No version set selected":"Aucun ensemble de versions sélectionné","Set Active Version":"Définir la version active","Make this version the active one for the set":"Faire de cette version la version active de l'ensemble","Cannot set active version (missing ids)":"Impossible de définir la version active (identifiants manquants)","Delete user":"Supprimer l'utilisateur","Delete user if they have no content (only empty root folder)":"Supprimer l'utilisateur s'il n'a aucun contenu (uniquement un dossier racine vide)","No user folder selected for cleanup":"Aucun dossier utilisateur sélectionné pour le nettoyage","This will delete the user if they have no media files and only an empty root folder.\n\nComments from this user will be preserved but marked as from a deleted user.\n\nAre you sure?":"Cela supprimera l'utilisateur s'il n'a aucun fichier multimédia et uniquement un dossier racine vide.\n\nLes commentaires de cet utilisateur seront conservés mais marqués comme provenant d'un utilisateur supprimé.\n\nÊtes-vous sûr ?","Cannot fetch contents of another user's folder":"Impossible de récupérer le contenu du dossier d'un autre utilisateur","Folder ID '{folder_id}' not found":"Dossier d'identifiant '{folder_id}' introuvable","Cannot trash another user's folder":"Impossible de mettre à la corbeille le dossier d'un autre utilisateur","folder\u0004UNNAMED":"SANS NOM","v{num} — {title}":"v{num} — {title}","folder\u0004Version":"Version","Switch version":"Changer de version","Cannot create folder in another user's folder":"Impossible de créer un dossier dans le dossier d'un autre utilisateur","Folder name too long":"Nom de dossier trop long","Folder name cannot be empty":"Le nom de dossier ne peut pas être vide","Item with this name already exists in the this folder":"Un élément portant ce nom existe déjà dans ce dossier","Only the folder owner can create shares":"Seul le propriétaire du dossier peut créer des partages","Only the folder owner can revoke sharing":"Seul le propriétaire du dossier peut révoquer le partage","

ADMIN – User Folders

":"

ADMIN – Dossiers des utilisateurs

","

The following users currently have a home folder and/or media files.
Uploading files or moving items to these folders will transfer ownership to that user.
Trashing a user's home folder will delete everything they have.

":"

Les utilisateurs suivants possèdent actuellement un dossier d'accueil et/ou des fichiers multimédias.
Téléverser des fichiers ou déplacer des éléments vers ces dossiers transférera la propriété à cet utilisateur.
Mettre à la corbeille le dossier d'accueil d'un utilisateur supprimera tout ce qu'il possède.

","This will delete ALL users who have no media files and only empty root folders.\n\nComments from deleted users will be preserved but marked as from deleted users.\n\nAre you sure?":"Cela supprimera TOUS les utilisateurs n'ayant aucun fichier multimédia et uniquement des dossiers racine vides.\n\nLes commentaires des utilisateurs supprimés seront conservés mais marqués comme provenant d'utilisateurs supprimés.\n\nÊtes-vous sûr ?","Delete all users without media":"Supprimer tous les utilisateurs sans média","

Use the drop zone to upload media files, or right-click on the empty space above to create a folder.

":"

Utilisez la zone de dépôt pour téléverser des fichiers multimédias, ou faites un clic droit sur l'espace vide ci-dessus pour créer un dossier.

","

After that, drag items to reorder, or drop them into folders. Hold shift to multi-select.

":"

Ensuite, faites glisser les éléments pour les réorganiser, ou déposez-les dans des dossiers. Maintenez Maj pour sélectionner plusieurs éléments.

","Root folder":"Dossier racine","folder\u0004Home":"Accueil","Command argument '{key}' must be an integer":"L'argument de commande '{key}' doit être un entier","This shared folder link is invalid or has been revoked":"Ce lien de dossier partagé est invalide ou a été révoqué","new_folder command missing 'name' argument":"La commande new_folder manque de l'argument 'name'","set_folder_kind requires 'id' and 'kind'":"set_folder_kind nécessite 'id' et 'kind'","Invalid folder kind: {kind}":"Type de dossier invalide : {kind}","Folder ID '{id}' not found":"Dossier d'identifiant '{id}' introuvable","Cannot change another user's folder":"Impossible de modifier le dossier d'un autre utilisateur","Cannot turn the root folder into a version set":"Impossible de transformer le dossier racine en ensemble de versions","Cannot make a version set from a folder that contains subfolders":"Impossible de créer un ensemble de versions à partir d'un dossier contenant des sous-dossiers","A version set needs at least one media file":"Un ensemble de versions nécessite au moins un fichier multimédia","Make Versioned works on media files only; deselect folders":"Rendre versionné ne fonctionne que sur les fichiers multimédias ; désélectionnez les dossiers","Select at least one media file to make versioned":"Sélectionnez au moins un fichier multimédia à rendre versionné","Selected media file is not in any folder":"Le fichier multimédia sélectionné n'est dans aucun dossier","Cannot modify another user's folder":"Impossible de modifier le dossier d'un autre utilisateur","All selected media files must be in the same folder to group them into a version set":"Tous les fichiers multimédias sélectionnés doivent être dans le même dossier pour les regrouper en un ensemble de versions","Version Set":"Ensemble de versions","set_active_version requires 'folder_id' and 'media_file_id'":"set_active_version nécessite 'folder_id' et 'media_file_id'","Cannot change another user's version set":"Impossible de modifier l'ensemble de versions d'un autre utilisateur","That media file is not in this version set":"Ce fichier multimédia ne fait pas partie de cet ensemble de versions","rename_folder command missing 'id' or 'new_name' argument":"La commande rename_folder manque de l'argument 'id' ou 'new_name'","trash_folder command missing 'id' argument":"La commande trash_folder manque de l'argument 'id'","share_folder command missing 'id' argument":"La commande share_folder manque de l'argument 'id'","Server URL base not configured - cannot generate shareable URLs":"Base d'URL du serveur non configurée — impossible de générer des URL de partage","Folder shared. Use popup/'Copy URL' to get a link.":"Dossier partagé. Utilisez la fenêtre contextuelle / 'Copier l'URL' pour obtenir un lien.","Folder sharing token created for '{title}'.":"Jeton de partage de dossier créé pour '{title}'.","revoke_share command missing 'id' argument":"La commande revoke_share manque de l'argument 'id'","Folder sharing has been revoked":"Le partage du dossier a été révoqué","This folder is not currently shared":"Ce dossier n'est pas actuellement partagé","cleanup_empty_user command missing 'folder_id' argument":"La commande cleanup_empty_user manque de l'argument 'folder_id'","Only admin can clean up users":"Seul un administrateur peut nettoyer les utilisateurs","Cleaned up {count} empty user":"{count} utilisateurs vides nettoyés","Comments from deleted users are preserved but marked as from deleted users.":"Les commentaires des utilisateurs supprimés sont conservés mais marqués comme provenant d'utilisateurs supprimés.","No empty users found to clean up":"Aucun utilisateur vide à nettoyer","User '{id}' has been cleaned up (deleted)":"L'utilisateur '{id}' a été nettoyé (supprimé)","Comments from this user are preserved but marked as from a deleted user.":"Les commentaires de cet utilisateur sont conservés mais marqués comme provenant d'un utilisateur supprimé.","User '{id}' was not deleted":"L'utilisateur '{id}' n'a pas été supprimé","User still has media files or non-empty folders.":"L'utilisateur possède encore des fichiers multimédias ou des dossiers non vides.","Unknown organizer command: {cmd}":"Commande d'organiseur inconnue : {cmd}"} +{"Internal error in authz: {desc}":"Erreur interne d'autorisation : {desc}","Permission denied":"Permission refusée","Permission denied: {desc}":"Permission refusée : {desc}","No such media file.":"Fichier multimédia introuvable.","You have no media yet.":"Vous n'avez pas encore de média.","All your media files":"Tous vos fichiers multimédias","Your Media":"Vos médias","{kind} deleted.":"{kind} supprimé.","{kind} deleted, but cleanup had errors.":"{kind} supprimé, mais le nettoyage a rencontré des erreurs.","Invalid file name (must contain a letter or number)":"Nom de fichier invalide (doit contenir une lettre ou un chiffre)","Name too long (max 160)":"Nom trop long (160 maximum)","{kind} renamed.":"{kind} renommé.","New name: '{name}'":"Nouveau nom : '{name}'","Failed to edit comment.":"Échec de la modification du commentaire.","No such comment. Cannot edit.":"Commentaire introuvable. Modification impossible.","Failed to delete comment.":"Échec de la suppression du commentaire.","You can only delete your own comments":"Vous ne pouvez supprimer que vos propres commentaires","Comment has replies. Cannot delete.":"Le commentaire a des réponses. Suppression impossible.","No such comment. Cannot delete.":"Commentaire introuvable. Suppression impossible.","Failed to add subtitle.":"Échec de l'ajout du sous-titre.","Subtitle file already exists: '{filename}'":"Le fichier de sous-titres existe déjà : '{filename}'","WebVTT file already exists: '{filename}'":"Le fichier WebVTT existe déjà : '{filename}'","Failed to join collab session: {error}":"Échec de la connexion à la session collaborative : {error}","Report rejected: no active collab session.":"Rapport rejeté : aucune session collaborative active.","No organizer session.":"Aucune session d'organiseur.","Missing command from client: {request}":"Commande manquante du client : {request}","Cmd '{cmd}' failed: {error}":"Échec de la commande '{cmd}' : {error}","Rename item":"Renommer l'élément","Unknown item type in rename action. Please report this bug.":"Type d'élément inconnu dans l'action renommer. Veuillez signaler ce bug.","Rename":"Renommer","Rename selected items":"Renommer les éléments sélectionnés","Are you sure you want to trash this item?":"Voulez-vous vraiment mettre cet élément à la corbeille ?","Are you sure you want to trash '{title}'?":"Voulez-vous vraiment mettre '{title}' à la corbeille ?","Are you sure you want to trash folder '{title}' and ALL CONTENTS?":"Voulez-vous vraiment mettre le dossier '{title}' et TOUT SON CONTENU à la corbeille ?","Are you sure you want to trash ALL selected items?":"Voulez-vous vraiment mettre TOUS les éléments sélectionnés à la corbeille ?","Unknown item type in trash action. Please report this bug.":"Type d'élément inconnu dans l'action corbeille. Veuillez signaler ce bug.","Trash":"Corbeille","Trash selected items":"Mettre les éléments sélectionnés à la corbeille","Are you sure you want to delete this subtitle?":"Voulez-vous vraiment supprimer ce sous-titre ?","Invalid time offset: {offset}":"Décalage temporel invalide : {offset}","Server version too old (v{version}). Please update server.":"Version du serveur trop ancienne (v{version}). Veuillez mettre à jour le serveur.","Client version too old (client v{clientVersion} for server v{serverVersion}). Please update client.":"Version du client trop ancienne (client v{clientVersion} pour le serveur v{serverVersion}). Veuillez mettre à jour le client.","Bad video open request. See log.":"Requête d'ouverture vidéo incorrecte. Consultez le journal.","Connecting server...":"Connexion au serveur...","View connection errors":"Voir les erreurs de connexion","See browser JS console for more details.":"Consultez la console JS du navigateur pour plus de détails.","Sort: date posted":"Tri : date de publication","Sort: timecode":"Tri : timecode","Subtitles":"Sous-titres","collab\u0004Collaborative viewing session active.":"Session de visionnage collaboratif active.","collab\u0004Session ID is {id}":"Identifiant de session : {id}","collab\u0004Actions like seek, play and draw are mirrored to all participants.":"Les actions comme le déplacement, la lecture et le dessin sont synchronisées avec tous les participants.","collab\u0004To invite people, copy browser URL and send it to them.":"Pour inviter des personnes, copiez l'URL du navigateur et envoyez-la leur.","collab\u0004Exit by clicking the green icon in header.":"Quittez en cliquant sur l'icône verte dans l'en-tête.","collab\u0004Understood":"Compris","Drop video, audio and image files here to upload":"Déposez ici des fichiers vidéo, audio et image pour les téléverser","Latest messages":"Derniers messages","Basic auth logout failed.\nStatus code from {url}: {status} (not 401)":"Échec de la déconnexion (authentification de base).\nCode de statut de {url} : {status} (pas 401)","Clapshot Client version {version}\n\nVisit the project page at:\nhttps://github.com/elonen/clapshot\n":"Client Clapshot version {version}\n\nVisitez la page du projet à :\nhttps://github.com/elonen/clapshot\n","Link copied to clipboard.\nSend it to reviewers who have user accounts here.":"Lien copié dans le presse-papiers.\nEnvoyez-le aux relecteurs qui possèdent un compte ici.","Share to logged in users":"Partager avec les utilisateurs connectés","Download original":"Télécharger l'original","collab\u0004Leave collaborative Session":"Quitter la session collaborative","collab\u0004Collaboration is disabled here":"La collaboration est désactivée ici","collab\u0004Start Collaborative Session":"Démarrer une session collaborative","Experimental tools":"Outils expérimentaux","Import EDL as Comments":"Importer EDL comme commentaires","Export Comments":"Exporter les commentaires","menu\u0004Language":"Langue","Logout":"Déconnexion","menu\u0004About":"À propos","Reload page to log in again.":"Rechargez la page pour vous reconnecter.","{percent}% uploaded... please wait":"{percent}% téléversé... veuillez patienter","Upload Failed":"Échec du téléversement","Upload Aborted":"Téléversement annulé","Uploading: {filename}...":"Téléversement : {filename}...","Drop rejected. Only video, audio and image files are allowed.":"Dépôt refusé. Seuls les fichiers vidéo, audio et image sont acceptés.","This target accepts media — folders can’t be dropped here.":"Cette cible accepte des médias — les dossiers ne peuvent pas y être déposés.","Delete comment?":"Supprimer le commentaire ?","Link copied to clipboard":"Lien copié dans le presse-papiers","Copy link":"Copier le lien","(edited)":"(modifié)","comment\u0004Reply":"Répondre","comment\u0004Edit":"Modifier","comment\u0004Del":"Suppr.","Your reply...":"Votre réponse...","comment\u0004Undo":"Annuler","comment\u0004Redo":"Rétablir","Add a comment - at current time...":"Ajouter un commentaire — à l'instant actuel...","Add a comment...":"Ajouter un commentaire...","Comment is time specific?":"Commentaire lié à un instant précis ?","Draw on video":"Dessiner sur la vidéo","comment\u0004Send":"Envoyer","Edit subtitle":"Modifier le sous-titre","subtitle\u0004Title":"Titre","Language code":"Code de langue","Time offset (sec)":"Décalage temporel (sec)","Default Subtitle":"Sous-titre par défaut","subtitle\u0004Save":"Enregistrer","subtitle\u0004Download":"Télécharger","subtitle\u0004Del":"Suppr.","Seek failed to: {target}":"Échec du déplacement vers : {target}","Video loading not done? Cannot enable drawing.":"Chargement de la vidéo non terminé ? Impossible d'activer le dessin.","Failed to show image.":"Échec de l'affichage de l'image.","No time spans found when parsing EDL.":"Aucune plage temporelle trouvée lors de l'analyse de l'EDL.","Frame rate":"Fréquence d'images","Parsed spans":"Plages analysées","Export format":"Format d'exportation","Format options":"Options de format","No comments to export.":"Aucun commentaire à exporter.","Multiple root folders in DB. Please report to support.":"Plusieurs dossiers racine dans la base de données. Veuillez le signaler au support.","Users should have one root folder. This inconsistency was fixed, but your home view might contain unexpected folders.":"Les utilisateurs ne devraient avoir qu'un seul dossier racine. Cette incohérence a été corrigée, mais votre vue d'accueil peut contenir des dossiers inattendus.","Home of '{name}'":"Accueil de '{name}'","Destination folder not found":"Dossier de destination introuvable","A version set can only contain media files":"Un ensemble de versions ne peut contenir que des fichiers multimédias","Folder id '{id}' not found":"Dossier d'identifiant '{id}' introuvable","Cannot move a folder into itself":"Impossible de déplacer un dossier dans lui-même","Cannot move another user's folder":"Impossible de déplacer le dossier d'un autre utilisateur","Folder with ID '{id}' is a root folder? Cannot move.":"Le dossier d'identifiant '{id}' est un dossier racine ? Déplacement impossible.","Media file '{id}' not found":"Fichier multimédia '{id}' introuvable","Cannot move another user's media file":"Impossible de déplacer le fichier multimédia d'un autre utilisateur","Parent folder {id} not found":"Dossier parent {id} introuvable","Cannot reorder items in another user's folder":"Impossible de réorganiser les éléments dans le dossier d'un autre utilisateur","No folder ID in UI listing, cannot reorder":"Aucun identifiant de dossier dans la liste de l'interface, réorganisation impossible","New folder":"Nouveau dossier","Create a new folder":"Créer un nouveau dossier","Name for the new folder":"Nom du nouveau dossier","Move to parent":"Déplacer vers le parent","Move item to parent folder":"Déplacer l'élément vers le dossier parent","Share folder":"Partager le dossier","Create a shareable link to this folder":"Créer un lien de partage vers ce dossier","No folder selected to share":"Aucun dossier sélectionné à partager","Stop sharing":"Arrêter le partage","Revoke the shared link for this folder":"Révoquer le lien partagé de ce dossier","No folder selected to unshare":"Aucun dossier sélectionné pour arrêter le partage","Are you sure you want to revoke the shared link for this folder?":"Voulez-vous vraiment révoquer le lien partagé de ce dossier ?","Shared link copied to clipboard!\n\nNOTE: Sharing a folder reveals direct links to all files currently in it, effectively giving recipient PERMANENT access to them, even if remove the folder share later.":"Lien partagé copié dans le presse-papiers !\n\nREMARQUE : partager un dossier expose des liens directs vers tous les fichiers qu'il contient actuellement, donnant ainsi au destinataire un accès PERMANENT à ceux-ci, même si vous arrêtez le partage du dossier plus tard.","Copy URL":"Copier l'URL","Copy the shared link to clipboard":"Copier le lien partagé dans le presse-papiers","No shared link available for this folder":"Aucun lien partagé disponible pour ce dossier","Copy this shared link:":"Copiez ce lien partagé :","Into Version Set":"En ensemble de versions","Turn this folder into a version set (revisions of one media file)":"Transformer ce dossier en ensemble de versions (révisions d'un même fichier multimédia)","No folder selected":"Aucun dossier sélectionné","Into Normal Folder":"En dossier normal","Turn this version set back into a normal folder":"Retransformer cet ensemble de versions en dossier normal","Make Versioned":"Rendre versionné","Group the selected media files into a new version set":"Regrouper les fichiers multimédias sélectionnés en un nouvel ensemble de versions","No media files selected":"Aucun fichier multimédia sélectionné","Manage Versions":"Gérer les versions","Open the version set to manage its versions":"Ouvrir l'ensemble de versions pour gérer ses versions","No version set selected":"Aucun ensemble de versions sélectionné","Set Active Version":"Définir la version active","Make this version the active one for the set":"Faire de cette version la version active de l'ensemble","Cannot set active version (missing ids)":"Impossible de définir la version active (identifiants manquants)","This will delete the user if they have no media files and only an empty root folder.\n\nComments from this user will be preserved but marked as from a deleted user.\n\nAre you sure?":"Cela supprimera l'utilisateur s'il n'a aucun fichier multimédia et uniquement un dossier racine vide.\n\nLes commentaires de cet utilisateur seront conservés mais marqués comme provenant d'un utilisateur supprimé.\n\nÊtes-vous sûr ?","Delete user":"Supprimer l'utilisateur","Delete user if they have no content (only empty root folder)":"Supprimer l'utilisateur s'il n'a aucun contenu (uniquement un dossier racine vide)","No user folder selected for cleanup":"Aucun dossier utilisateur sélectionné pour le nettoyage","Cannot fetch contents of another user's folder":"Impossible de récupérer le contenu du dossier d'un autre utilisateur","Folder ID '{folder_id}' not found":"Dossier d'identifiant '{folder_id}' introuvable","Cannot trash another user's folder":"Impossible de mettre à la corbeille le dossier d'un autre utilisateur","folder\u0004UNNAMED":"SANS NOM","v{num} — {title}":"v{num} — {title}","folder\u0004Version":"Version","Switch version":"Changer de version","Cannot create folder in another user's folder":"Impossible de créer un dossier dans le dossier d'un autre utilisateur","Folder name too long":"Nom de dossier trop long","Folder name cannot be empty":"Le nom de dossier ne peut pas être vide","Item with this name already exists in the this folder":"Un élément portant ce nom existe déjà dans ce dossier","Only the folder owner can create shares":"Seul le propriétaire du dossier peut créer des partages","Only the folder owner can revoke sharing":"Seul le propriétaire du dossier peut révoquer le partage","

ADMIN – User Folders

":"

ADMIN – Dossiers des utilisateurs

","

The following users currently have a home folder and/or media files.
Uploading files or moving items to these folders will transfer ownership to that user.
Trashing a user's home folder will delete everything they have.

":"

Les utilisateurs suivants possèdent actuellement un dossier d'accueil et/ou des fichiers multimédias.
Téléverser des fichiers ou déplacer des éléments vers ces dossiers transférera la propriété à cet utilisateur.
Mettre à la corbeille le dossier d'accueil d'un utilisateur supprimera tout ce qu'il possède.

","This will delete ALL users who have no media files and only empty root folders.\n\nComments from deleted users will be preserved but marked as from deleted users.\n\nAre you sure?":"Cela supprimera TOUS les utilisateurs n'ayant aucun fichier multimédia et uniquement des dossiers racine vides.\n\nLes commentaires des utilisateurs supprimés seront conservés mais marqués comme provenant d'utilisateurs supprimés.\n\nÊtes-vous sûr ?","Delete all users without media":"Supprimer tous les utilisateurs sans média","

Use the drop zone to upload media files, or right-click on the empty space above to create a folder.

":"

Utilisez la zone de dépôt pour téléverser des fichiers multimédias, ou faites un clic droit sur l'espace vide ci-dessus pour créer un dossier.

","

After that, drag items to reorder, or drop them into folders. Hold shift to multi-select.

":"

Ensuite, faites glisser les éléments pour les réorganiser, ou déposez-les dans des dossiers. Maintenez Maj pour sélectionner plusieurs éléments.

","Root folder":"Dossier racine","folder\u0004Home":"Accueil","Command argument '{key}' must be an integer":"L'argument de commande '{key}' doit être un entier","This shared folder link is invalid or has been revoked":"Ce lien de dossier partagé est invalide ou a été révoqué","new_folder command missing 'name' argument":"La commande new_folder manque de l'argument 'name'","set_folder_kind requires 'id' and 'kind'":"set_folder_kind nécessite 'id' et 'kind'","Invalid folder kind: {kind}":"Type de dossier invalide : {kind}","Folder ID '{id}' not found":"Dossier d'identifiant '{id}' introuvable","Cannot change another user's folder":"Impossible de modifier le dossier d'un autre utilisateur","Cannot turn the root folder into a version set":"Impossible de transformer le dossier racine en ensemble de versions","Cannot make a version set from a folder that contains subfolders":"Impossible de créer un ensemble de versions à partir d'un dossier contenant des sous-dossiers","A version set needs at least one media file":"Un ensemble de versions nécessite au moins un fichier multimédia","Make Versioned works on media files only; deselect folders":"Rendre versionné ne fonctionne que sur les fichiers multimédias ; désélectionnez les dossiers","Select at least one media file to make versioned":"Sélectionnez au moins un fichier multimédia à rendre versionné","Selected media file is not in any folder":"Le fichier multimédia sélectionné n'est dans aucun dossier","Cannot modify another user's folder":"Impossible de modifier le dossier d'un autre utilisateur","All selected media files must be in the same folder to group them into a version set":"Tous les fichiers multimédias sélectionnés doivent être dans le même dossier pour les regrouper en un ensemble de versions","Version Set":"Ensemble de versions","set_active_version requires 'folder_id' and 'media_file_id'":"set_active_version nécessite 'folder_id' et 'media_file_id'","Cannot change another user's version set":"Impossible de modifier l'ensemble de versions d'un autre utilisateur","That media file is not in this version set":"Ce fichier multimédia ne fait pas partie de cet ensemble de versions","rename_folder command missing 'id' or 'new_name' argument":"La commande rename_folder manque de l'argument 'id' ou 'new_name'","trash_folder command missing 'id' argument":"La commande trash_folder manque de l'argument 'id'","share_folder command missing 'id' argument":"La commande share_folder manque de l'argument 'id'","Server URL base not configured - cannot generate shareable URLs":"Base d'URL du serveur non configurée — impossible de générer des URL de partage","Folder shared. Use popup/'Copy URL' to get a link.":"Dossier partagé. Utilisez la fenêtre contextuelle / 'Copier l'URL' pour obtenir un lien.","Folder sharing token created for '{title}'.":"Jeton de partage de dossier créé pour '{title}'.","revoke_share command missing 'id' argument":"La commande revoke_share manque de l'argument 'id'","Folder sharing has been revoked":"Le partage du dossier a été révoqué","This folder is not currently shared":"Ce dossier n'est pas actuellement partagé","cleanup_empty_user command missing 'folder_id' argument":"La commande cleanup_empty_user manque de l'argument 'folder_id'","Only admin can clean up users":"Seul un administrateur peut nettoyer les utilisateurs","Cleaned up {count} empty user":"{count} utilisateurs vides nettoyés","Comments from deleted users are preserved but marked as from deleted users.":"Les commentaires des utilisateurs supprimés sont conservés mais marqués comme provenant d'utilisateurs supprimés.","No empty users found to clean up":"Aucun utilisateur vide à nettoyer","User '{id}' has been cleaned up (deleted)":"L'utilisateur '{id}' a été nettoyé (supprimé)","Comments from this user are preserved but marked as from a deleted user.":"Les commentaires de cet utilisateur sont conservés mais marqués comme provenant d'un utilisateur supprimé.","User '{id}' was not deleted":"L'utilisateur '{id}' n'a pas été supprimé","User still has media files or non-empty folders.":"L'utilisateur possède encore des fichiers multimédias ou des dossiers non vides.","Unknown organizer command: {cmd}":"Commande d'organiseur inconnue : {cmd}"} diff --git a/client/src/i18n/locales/zh.json b/client/src/i18n/locales/zh.json index 4c98dddc..5fb5e526 100644 --- a/client/src/i18n/locales/zh.json +++ b/client/src/i18n/locales/zh.json @@ -1 +1 @@ -{"Internal error in authz: {desc}":"授权内部错误:{desc}","Permission denied":"没有权限","Permission denied: {desc}":"没有权限:{desc}","No such media file.":"没有此媒体文件。","You have no media yet.":"你还没有任何媒体文件。","All your media files":"你的所有媒体文件","Your Media":"我的媒体","{kind} deleted.":"已删除{kind}。","{kind} deleted, but cleanup had errors.":"已删除{kind},但清理过程中出现错误。","Invalid file name (must contain a letter or number)":"文件名无效(必须包含字母或数字)","Name too long (max 160)":"名称过长(最多 160 个字符)","{kind} renamed.":"已重命名{kind}。","New name: '{name}'":"新名称:“{name}”","Failed to edit comment.":"编辑评论失败。","No such comment. Cannot edit.":"没有此评论,无法编辑。","Failed to delete comment.":"删除评论失败。","You can only delete your own comments":"你只能删除自己的评论","Comment has replies. Cannot delete.":"评论已有回复,无法删除。","No such comment. Cannot delete.":"没有此评论,无法删除。","Failed to add subtitle.":"添加字幕失败。","Subtitle file already exists: '{filename}'":"字幕文件已存在:“{filename}”","WebVTT file already exists: '{filename}'":"WebVTT 文件已存在:“{filename}”","Failed to join collab session: {error}":"加入协同会话失败:{error}","Report rejected: no active collab session.":"报告被拒绝:没有活动的协同会话。","No organizer session.":"没有 organizer 会话。","Missing command from client: {request}":"客户端缺少命令:{request}","Cmd '{cmd}' failed: {error}":"命令“{cmd}”执行失败:{error}","Rename item":"重命名项目","Unknown item type in rename action. Please report this bug.":"重命名操作中存在未知的项目类型。请报告此错误。","Rename":"重命名","Rename selected items":"重命名所选项目","Are you sure you want to trash this item?":"确定要将此项目移至回收站吗?","Are you sure you want to trash '{title}'?":"确定要将 '{title}' 移至回收站吗?","Are you sure you want to trash folder '{title}' and ALL CONTENTS?":"确定要将文件夹 '{title}' 及其全部内容移至回收站吗?","Are you sure you want to trash ALL selected items?":"确定要将所有选定项目移至回收站吗?","Unknown item type in trash action. Please report this bug.":"回收站操作中存在未知的项目类型。请报告此错误。","Trash":"移至回收站","Trash selected items":"将所选项目移至回收站","Are you sure you want to delete this subtitle?":"确定要删除此字幕吗?","Invalid time offset: {offset}":"时间偏移无效:{offset}","Server version too old (v{version}). Please update server.":"服务器版本过旧(v{version}),请更新服务器。","Client version too old (client v{clientVersion} for server v{serverVersion}). Please update client.":"客户端版本过旧(客户端 v{clientVersion} 对应服务器 v{serverVersion}),请更新客户端。","Bad video open request. See log.":"视频打开请求无效,请查看日志。","Connecting server...":"正在连接服务器...","View connection errors":"查看连接错误","See browser JS console for more details.":"更多详情请查看浏览器 JS 控制台。","Sort: date posted":"排序:发布日期","Sort: timecode":"排序:时间码","Subtitles":"字幕","collab\u0004Collaborative viewing session active.":"协同观看会话已启用。","collab\u0004Session ID is {id}":"会话 ID:{id}","collab\u0004Actions like seek, play and draw are mirrored to all participants.":"寻址、播放、绘图等操作会同步给所有参与者。","collab\u0004To invite people, copy browser URL and send it to them.":"邀请他人时,复制浏览器链接并发送给他们。","collab\u0004Exit by clicking the green icon in header.":"点击标题栏中的绿色图标退出。","collab\u0004Understood":"知道了","Drop video, audio and image files here to upload":"将视频、音频或图片文件拖到此处上传","Latest messages":"最新消息","Basic auth logout failed.\nStatus code from {url}: {status} (not 401)":"Basic 认证退出失败。\n来自 {url} 的状态码:{status}(非 401)","Clapshot Client version {version}\n\nVisit the project page at:\nhttps://github.com/elonen/clapshot\n":"Clapshot 客户端版本 {version}\n\n请访问项目主页:\nhttps://github.com/elonen/clapshot\n","Link copied to clipboard.\nSend it to reviewers who have user accounts here.":"链接已复制到剪贴板。\n请将其发送给在本系统拥有用户账户的观看者。","Share to logged in users":"分享给已登录用户","Download original":"下载原文件","collab\u0004Leave collaborative Session":"离开协同会话","collab\u0004Collaboration is disabled here":"此处无法使用协同会话","collab\u0004Start Collaborative Session":"开始协同会话","Experimental tools":"实验工具","Import EDL as Comments":"导入 EDL 为评论","Export Comments":"导出评论","menu\u0004Language":"语言","Logout":"退出","menu\u0004About":"关于","Reload page to log in again.":"请重新加载页面以再次登录。","{percent}% uploaded... please wait":"已上传 {percent}% ... 请稍候","Upload Failed":"上传失败","Upload Aborted":"上传已中止","Uploading: {filename}...":"正在上传:{filename}...","Drop rejected. Only video, audio and image files are allowed.":"不支持此文件类型。仅支持视频、音频和图片。","This target accepts media — folders can’t be dropped here.":"此目标只接受媒体文件——不能将文件夹拖放到此处。","Delete comment?":"删除评论?","Link copied to clipboard":"链接已复制","Copy link":"复制链接","(edited)":"(已编辑)","comment\u0004Reply":"回复","comment\u0004Edit":"编辑","comment\u0004Del":"删除","Your reply...":"请输入回复...","comment\u0004Undo":"撤销","comment\u0004Redo":"重做","Add a comment - at current time...":"添加评论 —— 使用当前时间点...","Add a comment...":"添加评论...","Comment is time specific?":"这条评论包含时间码?","Draw on video":"在视频上绘制","comment\u0004Send":"发送","Edit subtitle":"编辑字幕","subtitle\u0004Title":"标题","Language code":"语言代码","Time offset (sec)":"时间偏移(秒)","Default Subtitle":"默认字幕","subtitle\u0004Save":"保存","subtitle\u0004Download":"下载","subtitle\u0004Del":"删除","Seek failed to: {target}":"跳转失败:{target}","Video loading not done? Cannot enable drawing.":"视频尚未加载完成?无法启用绘制。","Failed to show image.":"显示图像失败。","No time spans found when parsing EDL.":"解析 EDL 时未找到任何时间区间。","Parsed spans":"已解析的时间区间","Export format":"导出格式","Frame rate":"帧率(FPS)","Format options":"格式选项","No comments to export.":"没有可导出的评论。","Multiple root folders in DB. Please report to support.":"数据库中存在多个根文件夹,请联系技术支持。","Users should have one root folder. This inconsistency was fixed, but your home view might contain unexpected folders.":"每个用户应只有一个根文件夹。此不一致问题已修复,但你的主页视图中可能会出现意外的文件夹。","Home of '{name}'":"“{name}”的主页","Destination folder not found":"未找到目标文件夹","A version set can only contain media files":"版本集只能包含媒体文件","Folder id '{id}' not found":"未找到文件夹 ID“{id}”","Cannot move a folder into itself":"无法将文件夹移动到其自身内部","Cannot move another user's folder":"无法移动其他用户的文件夹","Folder with ID '{id}' is a root folder? Cannot move.":"ID 为“{id}”的文件夹是根文件夹?无法移动。","Media file '{id}' not found":"未找到媒体文件“{id}”","Cannot move another user's media file":"无法移动其他用户的媒体文件","Parent folder {id} not found":"未找到父文件夹 {id}","Cannot reorder items in another user's folder":"无法对其他用户文件夹中的项目重新排序","No folder ID in UI listing, cannot reorder":"UI 列表中没有文件夹 ID,无法重新排序","New folder":"新建文件夹","Create a new folder":"新建文件夹","Name for the new folder":"新文件夹的名称","Move to parent":"移到上级文件夹","Move item to parent folder":"将项目移动到上级文件夹","Share folder":"分享文件夹","Create a shareable link to this folder":"为此文件夹创建可分享链接","No folder selected to share":"未选择要分享的文件夹","Stop sharing":"停止分享","Revoke the shared link for this folder":"撤销此文件夹的分享链接","No folder selected to unshare":"未选择要取消分享的文件夹","Are you sure you want to revoke the shared link for this folder?":"确定要撤销此文件夹的分享链接吗?","Copy URL":"复制链接","Copy the shared link to clipboard":"将分享链接复制到剪贴板","No shared link available for this folder":"此文件夹没有可用的分享链接","Shared link copied to clipboard!\n\nNOTE: Sharing a folder reveals direct links to all files currently in it, effectively giving recipient PERMANENT access to them, even if remove the folder share later.":"分享链接已复制到剪贴板!\n\n注意:分享文件夹会公开其中当前所有文件的直接链接,实际上等于授予接收者对这些文件的永久访问权限,即使之后取消文件夹分享也是如此。","Copy this shared link:":"复制此分享链接:","Into Version Set":"转为版本集","Turn this folder into a version set (revisions of one media file)":"将此文件夹转换为版本集(同一媒体文件的多个修订版本)","No folder selected":"未选择文件夹","Into Normal Folder":"转为普通文件夹","Turn this version set back into a normal folder":"将此版本集转换回普通文件夹","Make Versioned":"合并为版本集","Group the selected media files into a new version set":"将所选媒体文件归入一个新的版本集","No media files selected":"未选择媒体文件","Manage Versions":"管理版本","Open the version set to manage its versions":"打开版本集以管理其版本","No version set selected":"未选择版本集","Set Active Version":"设为当前版本","Make this version the active one for the set":"将此版本设为该版本集的活动版本","Cannot set active version (missing ids)":"无法设置活动版本(缺少 ID)","Delete user":"删除用户","Delete user if they have no content (only empty root folder)":"如果用户没有任何内容(仅有空的根文件夹)则删除该用户","No user folder selected for cleanup":"未选择用于清理的用户文件夹","This will delete the user if they have no media files and only an empty root folder.\n\nComments from this user will be preserved but marked as from a deleted user.\n\nAre you sure?":"如果用户没有媒体文件且仅有一个空的根文件夹,此操作将删除该用户。\n\n该用户的评论会被保留,但会标记为来自已删除用户。\n\n确定要继续吗?","Cannot fetch contents of another user's folder":"无法获取其他用户文件夹的内容","Folder ID '{folder_id}' not found":"未找到文件夹 ID“{folder_id}”","Cannot trash another user's folder":"无法删除其他用户的文件夹","folder\u0004UNNAMED":"未命名","v{num} — {title}":"v{num} — {title}","folder\u0004Version":"版本","Switch version":"切换版本","Cannot create folder in another user's folder":"无法在其他用户的文件夹中创建文件夹","Folder name too long":"文件夹名称过长","Folder name cannot be empty":"文件夹名称不能为空","Item with this name already exists in the this folder":"此文件夹中已存在同名项目","Only the folder owner can create shares":"只有文件夹所有者才能创建分享","Only the folder owner can revoke sharing":"只有文件夹所有者才能撤销分享","

ADMIN – User Folders

":"

管理员 – 用户文件夹

","

The following users currently have a home folder and/or media files.
Uploading files or moving items to these folders will transfer ownership to that user.
Trashing a user's home folder will delete everything they have.

":"

以下用户当前拥有主页文件夹和/或媒体文件。
上传文件或将项目移动到这些文件夹会将所有权转移给该用户。
删除某用户的主页文件夹会删除他们的所有内容。

","This will delete ALL users who have no media files and only empty root folders.\n\nComments from deleted users will be preserved but marked as from deleted users.\n\nAre you sure?":"此操作将删除所有没有媒体文件且仅有空根文件夹的用户。\n\n已删除用户的评论会被保留,但会标记为来自已删除用户。\n\n确定要继续吗?","Delete all users without media":"删除所有没有媒体文件的用户","

Use the drop zone to upload media files, or right-click on the empty space above to create a folder.

":"

使用拖放区上传媒体文件,或右键点击上方空白处新建文件夹

","

After that, drag items to reorder, or drop them into folders. Hold shift to multi-select.

":"

之后,拖动项目可重新排序,或将其拖入文件夹。按住 Shift 可多选。

","Root folder":"根文件夹","folder\u0004Home":"主页","Command argument '{key}' must be an integer":"命令参数“{key}”必须是整数","This shared folder link is invalid or has been revoked":"此共享文件夹链接无效或已被撤销","new_folder command missing 'name' argument":"new_folder 命令缺少 'name' 参数","set_folder_kind requires 'id' and 'kind'":"set_folder_kind 需要 'id' 和 'kind'","Invalid folder kind: {kind}":"文件夹类型无效:{kind}","Folder ID '{id}' not found":"未找到文件夹 ID“{id}”","Cannot change another user's folder":"无法更改其他用户的文件夹","Cannot turn the root folder into a version set":"无法将根文件夹转换为版本集","Cannot make a version set from a folder that contains subfolders":"无法将包含子文件夹的文件夹转换为版本集","A version set needs at least one media file":"版本集至少需要一个媒体文件","Make Versioned works on media files only; deselect folders":"“转为版本”仅适用于媒体文件,请取消选择文件夹","Select at least one media file to make versioned":"请至少选择一个要转为版本的媒体文件","Selected media file is not in any folder":"所选媒体文件不在任何文件夹中","Cannot modify another user's folder":"无法修改其他用户的文件夹","All selected media files must be in the same folder to group them into a version set":"所有所选媒体文件必须位于同一文件夹中才能归入一个版本集","Version Set":"版本集","set_active_version requires 'folder_id' and 'media_file_id'":"set_active_version 需要 'folder_id' 和 'media_file_id'","Cannot change another user's version set":"无法更改其他用户的版本集","That media file is not in this version set":"该媒体文件不在此版本集中","rename_folder command missing 'id' or 'new_name' argument":"rename_folder 命令缺少 'id' 或 'new_name' 参数","trash_folder command missing 'id' argument":"trash_folder 命令缺少 'id' 参数","share_folder command missing 'id' argument":"share_folder 命令缺少 'id' 参数","Server URL base not configured - cannot generate shareable URLs":"未配置服务器 URL 基址 - 无法生成可分享的 URL","Folder shared. Use popup/'Copy URL' to get a link.":"文件夹已分享。请使用弹窗或“复制 URL”获取链接。","Folder sharing token created for '{title}'.":"已为“{title}”创建文件夹分享令牌。","revoke_share command missing 'id' argument":"revoke_share 命令缺少 'id' 参数","Folder sharing has been revoked":"文件夹分享已被撤销","This folder is not currently shared":"此文件夹当前未被分享","cleanup_empty_user command missing 'folder_id' argument":"cleanup_empty_user 命令缺少 'folder_id' 参数","Only admin can clean up users":"只有管理员才能清理用户","Cleaned up {count} empty user":"已清理 {count} 个空用户","Comments from deleted users are preserved but marked as from deleted users.":"已删除用户的评论会被保留,但会标记为来自已删除用户。","No empty users found to clean up":"未找到可清理的空用户","User '{id}' has been cleaned up (deleted)":"用户“{id}”已被清理(删除)","Comments from this user are preserved but marked as from a deleted user.":"此用户的评论会被保留,但会标记为来自已删除用户。","User '{id}' was not deleted":"用户“{id}”未被删除","User still has media files or non-empty folders.":"该用户仍有媒体文件或非空文件夹。","Unknown organizer command: {cmd}":"未知的 organizer 命令:{cmd}"} +{"Internal error in authz: {desc}":"授权内部错误:{desc}","Permission denied":"没有权限","Permission denied: {desc}":"没有权限:{desc}","No such media file.":"没有此媒体文件。","You have no media yet.":"你还没有任何媒体文件。","All your media files":"你的所有媒体文件","Your Media":"我的媒体","{kind} deleted.":"已删除{kind}。","{kind} deleted, but cleanup had errors.":"已删除{kind},但清理过程中出现错误。","Invalid file name (must contain a letter or number)":"文件名无效(必须包含字母或数字)","Name too long (max 160)":"名称过长(最多 160 个字符)","{kind} renamed.":"已重命名{kind}。","New name: '{name}'":"新名称:“{name}”","Failed to edit comment.":"编辑评论失败。","No such comment. Cannot edit.":"没有此评论,无法编辑。","Failed to delete comment.":"删除评论失败。","You can only delete your own comments":"你只能删除自己的评论","Comment has replies. Cannot delete.":"评论已有回复,无法删除。","No such comment. Cannot delete.":"没有此评论,无法删除。","Failed to add subtitle.":"添加字幕失败。","Subtitle file already exists: '{filename}'":"字幕文件已存在:“{filename}”","WebVTT file already exists: '{filename}'":"WebVTT 文件已存在:“{filename}”","Failed to join collab session: {error}":"加入协同会话失败:{error}","Report rejected: no active collab session.":"报告被拒绝:没有活动的协同会话。","No organizer session.":"没有 organizer 会话。","Missing command from client: {request}":"客户端缺少命令:{request}","Cmd '{cmd}' failed: {error}":"命令“{cmd}”执行失败:{error}","Rename item":"重命名项目","Unknown item type in rename action. Please report this bug.":"重命名操作中存在未知的项目类型。请报告此错误。","Rename":"重命名","Rename selected items":"重命名所选项目","Are you sure you want to trash this item?":"确定要将此项目移至回收站吗?","Are you sure you want to trash '{title}'?":"确定要将 '{title}' 移至回收站吗?","Are you sure you want to trash folder '{title}' and ALL CONTENTS?":"确定要将文件夹 '{title}' 及其全部内容移至回收站吗?","Are you sure you want to trash ALL selected items?":"确定要将所有选定项目移至回收站吗?","Unknown item type in trash action. Please report this bug.":"回收站操作中存在未知的项目类型。请报告此错误。","Trash":"移至回收站","Trash selected items":"将所选项目移至回收站","Comment not found":"未找到评论","Subtitle not found. See log.":"未找到字幕。请查看日志。","Error adding subtitle file. See log.":"添加字幕文件时出错。请查看日志。","Are you sure you want to delete this subtitle?":"确定要删除此字幕吗?","Invalid time offset: {offset}":"时间偏移无效:{offset}","Unknown URL parameter: '{key}'":"未知 URL 参数:'{key}'","Client error: {error}":"客户端错误:{error}","Server version too old (v{version}). Please update server.":"服务器版本过旧(v{version}),请更新服务器。","Client version too old (client v{clientVersion} for server v{serverVersion}). Please update client.":"客户端版本过旧(客户端 v{clientVersion} 对应服务器 v{serverVersion}),请更新客户端。","No user in welcome message":"欢迎消息中没有用户","Clapshot - {title}":"Clapshot - {title}","Home":"主页","Bad video open request. See log.":"视频打开请求无效,请查看日志。","{user} is controlling":"{user} 正在控制","No open action for item. See log.":"项目没有打开操作。请查看日志。","BUG: Unsupported script lang. See log.":"错误:不支持的脚本语言。请查看日志。","Organizer script error. See log.":"Organizer 脚本错误。请查看日志。","Connecting server...":"正在连接服务器...","View connection errors":"查看连接错误","See browser JS console for more details.":"更多详情请查看浏览器 JS 控制台。","Sort: date posted":"排序:发布日期","Sort: timecode":"排序:时间码","Subtitles":"字幕","Upload subtitles":"上传字幕","collab\u0004Collaborative viewing session active.":"协同观看会话已启用。","collab\u0004Session ID is {id}":"会话 ID:{id}","collab\u0004Actions like seek, play and draw are mirrored to all participants.":"寻址、播放、绘图等操作会同步给所有参与者。","collab\u0004To invite people, copy browser URL and send it to them.":"邀请他人时,复制浏览器链接并发送给他们。","collab\u0004Exit by clicking the green icon in header.":"点击标题栏中的绿色图标退出。","collab\u0004Understood":"知道了","Drop video, audio and image files here to upload":"将视频、音频或图片文件拖到此处上传","Latest messages":"最新消息","Basic auth logout failed.\nStatus code from {url}: {status} (not 401)":"Basic 认证退出失败。\n来自 {url} 的状态码:{status}(非 401)","Clapshot Client version {version}\n\nVisit the project page at:\nhttps://github.com/elonen/clapshot\n":"Clapshot 客户端版本 {version}\n\n请访问项目主页:\nhttps://github.com/elonen/clapshot\n","Link copied to clipboard.\nSend it to reviewers who have user accounts here.":"链接已复制到剪贴板。\n请将其发送给在本系统拥有用户账户的观看者。","Open menu":"打开菜单","Share to logged in users":"分享给已登录用户","Download original":"下载原文件","collab\u0004Leave collaborative Session":"离开协同会话","collab\u0004Collaboration is disabled here":"此处无法使用协同会话","collab\u0004Start Collaborative Session":"开始协同会话","Experimental tools":"实验工具","Import EDL as Comments":"导入 EDL 为评论","Export Comments":"导出评论","Processing...":"处理中...","menu\u0004Language":"语言","Logout":"退出","menu\u0004About":"关于","Reload page to log in again.":"请重新加载页面以再次登录。","OK":"确定","ERROR":"错误","PROGRESS":"进度","{percent}% uploaded... please wait":"已上传 {percent}% ... 请稍候","Upload Failed":"上传失败","Upload Aborted":"上传已中止","Uploading: {filename}...":"正在上传:{filename}...","Drop rejected. Only video, audio and image files are allowed.":"不支持此文件类型。仅支持视频、音频和图片。","UI BUG: item not found":"界面错误:未找到项目","UI BUG: item not found or missing openAction":"界面错误:未找到项目或缺少打开操作","UI BUG: popup container missing":"界面错误:弹出菜单容器缺失","UI BUG: popup container child missing hide()":"界面错误:弹出菜单子项缺少 hide()","Unknown item type":"未知项目类型","This target accepts media — folders can’t be dropped here.":"此目标只接受媒体文件——不能将文件夹拖放到此处。","(no date)":"(无日期)","video icon":"视频图标","Delete comment?":"删除评论?","Link copied to clipboard":"链接已复制","Copy link":"复制链接","(edited)":"(已编辑)","comment\u0004Reply":"回复","comment\u0004Edit":"编辑","comment\u0004Del":"删除","Your reply...":"请输入回复...","comment\u0004Undo":"撤销","comment\u0004Redo":"重做","Select {color} color":"选择 {color} 颜色","Add a comment - at current time...":"添加评论 —— 使用当前时间点...","Add a comment...":"添加评论...","Comment is time specific?":"这条评论包含时间码?","Draw on video":"在视频上绘制","comment\u0004Send":"发送","Edit subtitle":"编辑字幕","subtitle\u0004Title":"标题","Language code":"语言代码","ISO 639 language codes information":"ISO 639 语言代码信息","Time offset (sec)":"时间偏移(秒)","Default Subtitle":"默认字幕","subtitle\u0004Save":"保存","subtitle\u0004Download":"下载","subtitle\u0004Del":"删除","Seek failed to: {target}":"跳转失败:{target}","Video loading not done? Cannot enable drawing.":"视频尚未加载完成?无法启用绘制。","Failed to show image.":"显示图像失败。","Step backwards":"后退一帧","Play/Pause":"播放/暂停","Step forwards":"前进一帧","frame label\u0004FR":"帧","Set loop start to current frame":"将循环起点设为当前帧","Set loop end to current frame":"将循环终点设为当前帧","Toggle closed captioning":"切换字幕","Mute audio":"静音","Unmute audio":"取消静音","Toggle fullscreen (f)":"切换全屏 (f)","Toggle fullscreen":"切换全屏","No time spans found when parsing EDL.":"解析 EDL 时未找到任何时间区间。","Upload EDL":"上传 EDL","Frame rate":"帧率(FPS)","Add as comments":"添加为评论","Cancel":"取消","Parsed spans":"已解析的时间区间","Export format":"导出格式","Format options":"格式选项","Export":"导出","1 comment will be exported.":"将导出 1 条评论。","{n} comments will be exported.":"将导出 {n} 条评论。","No comments to export.":"没有可导出的评论。","Multiple root folders in DB. Please report to support.":"数据库中存在多个根文件夹,请联系技术支持。","Users should have one root folder. This inconsistency was fixed, but your home view might contain unexpected folders.":"每个用户应只有一个根文件夹。此不一致问题已修复,但你的主页视图中可能会出现意外的文件夹。","Home of '{name}'":"“{name}”的主页","Destination folder not found":"未找到目标文件夹","A version set can only contain media files":"版本集只能包含媒体文件","Folder id '{id}' not found":"未找到文件夹 ID“{id}”","Cannot move a folder into itself":"无法将文件夹移动到其自身内部","Cannot move another user's folder":"无法移动其他用户的文件夹","Folder with ID '{id}' is a root folder? Cannot move.":"ID 为“{id}”的文件夹是根文件夹?无法移动。","Media file '{id}' not found":"未找到媒体文件“{id}”","Cannot move another user's media file":"无法移动其他用户的媒体文件","Parent folder {id} not found":"未找到父文件夹 {id}","Cannot reorder items in another user's folder":"无法对其他用户文件夹中的项目重新排序","No folder ID in UI listing, cannot reorder":"UI 列表中没有文件夹 ID,无法重新排序","New folder":"新建文件夹","Create a new folder":"新建文件夹","Name for the new folder":"新文件夹的名称","Move to parent":"移到上级文件夹","Move item to parent folder":"将项目移动到上级文件夹","Share folder":"分享文件夹","Create a shareable link to this folder":"为此文件夹创建可分享链接","No folder selected to share":"未选择要分享的文件夹","Stop sharing":"停止分享","Revoke the shared link for this folder":"撤销此文件夹的分享链接","No folder selected to unshare":"未选择要取消分享的文件夹","Are you sure you want to revoke the shared link for this folder?":"确定要撤销此文件夹的分享链接吗?","Shared link copied to clipboard!\n\nNOTE: Sharing a folder reveals direct links to all files currently in it, effectively giving recipient PERMANENT access to them, even if remove the folder share later.":"分享链接已复制到剪贴板!\n\n注意:分享文件夹会公开其中当前所有文件的直接链接,实际上等于授予接收者对这些文件的永久访问权限,即使之后取消文件夹分享也是如此。","Copy URL":"复制链接","Copy the shared link to clipboard":"将分享链接复制到剪贴板","No shared link available for this folder":"此文件夹没有可用的分享链接","Copy this shared link:":"复制此分享链接:","Into Version Set":"转为版本集","Turn this folder into a version set (revisions of one media file)":"将此文件夹转换为版本集(同一媒体文件的多个修订版本)","No folder selected":"未选择文件夹","Into Normal Folder":"转为普通文件夹","Turn this version set back into a normal folder":"将此版本集转换回普通文件夹","Make Versioned":"合并为版本集","Group the selected media files into a new version set":"将所选媒体文件归入一个新的版本集","No media files selected":"未选择媒体文件","Manage Versions":"管理版本","Open the version set to manage its versions":"打开版本集以管理其版本","No version set selected":"未选择版本集","Set Active Version":"设为当前版本","Make this version the active one for the set":"将此版本设为该版本集的活动版本","Cannot set active version (missing ids)":"无法设置活动版本(缺少 ID)","This will delete the user if they have no media files and only an empty root folder.\n\nComments from this user will be preserved but marked as from a deleted user.\n\nAre you sure?":"如果用户没有媒体文件且仅有一个空的根文件夹,此操作将删除该用户。\n\n该用户的评论会被保留,但会标记为来自已删除用户。\n\n确定要继续吗?","Delete user":"删除用户","Delete user if they have no content (only empty root folder)":"如果用户没有任何内容(仅有空的根文件夹)则删除该用户","No user folder selected for cleanup":"未选择用于清理的用户文件夹","Cannot fetch contents of another user's folder":"无法获取其他用户文件夹的内容","Folder ID '{folder_id}' not found":"未找到文件夹 ID“{folder_id}”","Cannot trash another user's folder":"无法删除其他用户的文件夹","folder\u0004UNNAMED":"未命名","v{num} — {title}":"v{num} — {title}","folder\u0004Version":"版本","Switch version":"切换版本","Cannot create folder in another user's folder":"无法在其他用户的文件夹中创建文件夹","Folder name too long":"文件夹名称过长","Folder name cannot be empty":"文件夹名称不能为空","Item with this name already exists in the this folder":"此文件夹中已存在同名项目","Only the folder owner can create shares":"只有文件夹所有者才能创建分享","Only the folder owner can revoke sharing":"只有文件夹所有者才能撤销分享","

ADMIN – User Folders

":"

管理员 – 用户文件夹

","

The following users currently have a home folder and/or media files.
Uploading files or moving items to these folders will transfer ownership to that user.
Trashing a user's home folder will delete everything they have.

":"

以下用户当前拥有主页文件夹和/或媒体文件。
上传文件或将项目移动到这些文件夹会将所有权转移给该用户。
删除某用户的主页文件夹会删除他们的所有内容。

","This will delete ALL users who have no media files and only empty root folders.\n\nComments from deleted users will be preserved but marked as from deleted users.\n\nAre you sure?":"此操作将删除所有没有媒体文件且仅有空根文件夹的用户。\n\n已删除用户的评论会被保留,但会标记为来自已删除用户。\n\n确定要继续吗?","Delete all users without media":"删除所有没有媒体文件的用户","

Use the drop zone to upload media files, or right-click on the empty space above to create a folder.

":"

使用拖放区上传媒体文件,或右键点击上方空白处新建文件夹

","

After that, drag items to reorder, or drop them into folders. Hold shift to multi-select.

":"

之后,拖动项目可重新排序,或将其拖入文件夹。按住 Shift 可多选。

","Root folder":"根文件夹","folder\u0004Home":"主页","Command argument '{key}' must be an integer":"命令参数“{key}”必须是整数","This shared folder link is invalid or has been revoked":"此共享文件夹链接无效或已被撤销","new_folder command missing 'name' argument":"new_folder 命令缺少 'name' 参数","set_folder_kind requires 'id' and 'kind'":"set_folder_kind 需要 'id' 和 'kind'","Invalid folder kind: {kind}":"文件夹类型无效:{kind}","Folder ID '{id}' not found":"未找到文件夹 ID“{id}”","Cannot change another user's folder":"无法更改其他用户的文件夹","Cannot turn the root folder into a version set":"无法将根文件夹转换为版本集","Cannot make a version set from a folder that contains subfolders":"无法将包含子文件夹的文件夹转换为版本集","A version set needs at least one media file":"版本集至少需要一个媒体文件","Make Versioned works on media files only; deselect folders":"“转为版本”仅适用于媒体文件,请取消选择文件夹","Select at least one media file to make versioned":"请至少选择一个要转为版本的媒体文件","Selected media file is not in any folder":"所选媒体文件不在任何文件夹中","Cannot modify another user's folder":"无法修改其他用户的文件夹","All selected media files must be in the same folder to group them into a version set":"所有所选媒体文件必须位于同一文件夹中才能归入一个版本集","Version Set":"版本集","set_active_version requires 'folder_id' and 'media_file_id'":"set_active_version 需要 'folder_id' 和 'media_file_id'","Cannot change another user's version set":"无法更改其他用户的版本集","That media file is not in this version set":"该媒体文件不在此版本集中","rename_folder command missing 'id' or 'new_name' argument":"rename_folder 命令缺少 'id' 或 'new_name' 参数","trash_folder command missing 'id' argument":"trash_folder 命令缺少 'id' 参数","share_folder command missing 'id' argument":"share_folder 命令缺少 'id' 参数","Server URL base not configured - cannot generate shareable URLs":"未配置服务器 URL 基址 - 无法生成可分享的 URL","Folder shared. Use popup/'Copy URL' to get a link.":"文件夹已分享。请使用弹窗或“复制 URL”获取链接。","Folder sharing token created for '{title}'.":"已为“{title}”创建文件夹分享令牌。","revoke_share command missing 'id' argument":"revoke_share 命令缺少 'id' 参数","Folder sharing has been revoked":"文件夹分享已被撤销","This folder is not currently shared":"此文件夹当前未被分享","cleanup_empty_user command missing 'folder_id' argument":"cleanup_empty_user 命令缺少 'folder_id' 参数","Only admin can clean up users":"只有管理员才能清理用户","Cleaned up {count} empty user":"已清理 {count} 个空用户","Comments from deleted users are preserved but marked as from deleted users.":"已删除用户的评论会被保留,但会标记为来自已删除用户。","No empty users found to clean up":"未找到可清理的空用户","User '{id}' has been cleaned up (deleted)":"用户“{id}”已被清理(删除)","Comments from this user are preserved but marked as from a deleted user.":"此用户的评论会被保留,但会标记为来自已删除用户。","User '{id}' was not deleted":"用户“{id}”未被删除","User still has media files or non-empty folders.":"该用户仍有媒体文件或非空文件夹。","Unknown organizer command: {cmd}":"未知的 organizer 命令:{cmd}"} diff --git a/client/src/lib/NavBar.svelte b/client/src/lib/NavBar.svelte index 0d84fe0d..6a09416e 100644 --- a/client/src/lib/NavBar.svelte +++ b/client/src/lib/NavBar.svelte @@ -23,13 +23,17 @@ let { onbasicauthlogout, onaddcomments }: Props = $props(); let loggedOut = $state(false); let localeOptions = $state(availableLocales); -// Watch for (transcoding) progress reports from server, and update progress bar if one matches this item. +// Watch for (transcoding/upload) progress reports from server, and show a quick status bar for the current video. let videoProgressMsg: string | undefined = $state(undefined); - -onMount(async () => { - latestProgressReports.subscribe((reports: MediaProgressReport[]) => { - videoProgressMsg = reports.find((r: MediaProgressReport) => r.mediaFileId === $mediaFileId)?.msg; - }); +let videoProgressVal: number | undefined = $state(undefined); + +onMount(() => { + const unsubscribe = latestProgressReports.subscribe((reports: MediaProgressReport[]) => { + const match = reports.find((r: MediaProgressReport) => r.mediaFileId === $mediaFileId); + videoProgressMsg = match?.msg; + videoProgressVal = match?.progress; + }); + return () => unsubscribe(); }); $effect(() => { @@ -131,7 +135,7 @@ function addEDLComments(comments: Proto3.Comment[]) {
@@ -139,14 +143,14 @@ function addEDLComments(comments: Proto3.Comment[]) { {$t("Share to logged in users")} {#if $curVideo?.origUrl} - {$t("Download original")} + {$t("Download original")} {/if} {#if $collabId} {$t("Leave collaborative Session", { context: "collab" })} {:else if $playerHeaderHtml} {$t("Start Collaborative Session", { context: "collab" })} {:else} - {$t("Start Collaborative Session", { context: "collab" })} + {$t("Start Collaborative Session", { context: "collab" })} {/if} @@ -166,6 +170,17 @@ function addEDLComments(comments: Proto3.Comment[]) { {#if !$playerHeaderHtml} {$curVideo?.title} {/if} + + {#if videoProgressVal !== undefined} +
+
+ {videoProgressMsg || $t("Processing...")} +
+
+
+
+
+ {/if} {#if videoProgressMsg} {videoProgressMsg} {/if} diff --git a/client/src/lib/UserMessage.svelte b/client/src/lib/UserMessage.svelte index d3e6ae4a..c8969562 100644 --- a/client/src/lib/UserMessage.svelte +++ b/client/src/lib/UserMessage.svelte @@ -2,6 +2,7 @@ import { slide } from "svelte/transition"; import * as Proto3 from '@clapshot_protobuf/typescript'; import '@fortawesome/fontawesome-free/css/all.min.css'; +import { t } from '@/i18n'; interface Props { msg: Proto3.UserMessage; @@ -17,11 +18,11 @@ function isError(msg: Proto3.UserMessage): boolean { function msgTypeName(msg: Proto3.UserMessage): string { switch (msg.type) { case Proto3.UserMessage_Type.OK: - return 'OK'; + return $t('OK'); case Proto3.UserMessage_Type.ERROR: - return 'ERROR'; + return $t('ERROR'); case Proto3.UserMessage_Type.PROGRESS: - return 'PROGRESS'; + return $t('PROGRESS'); default: return ''; } diff --git a/client/src/lib/asset_browser/FileUpload.svelte b/client/src/lib/asset_browser/FileUpload.svelte index e33b0a81..a4d793f6 100644 --- a/client/src/lib/asset_browser/FileUpload.svelte +++ b/client/src/lib/asset_browser/FileUpload.svelte @@ -15,6 +15,7 @@ let files = { // Passed to HTTP POST request: listingData: Object; mediaFileAddedAction: string|undefined; + transcodePreferred?: boolean; children?: import('svelte').Snippet; } @@ -22,6 +23,7 @@ let files = { postUrl, listingData, mediaFileAddedAction, + transcodePreferred = true, children }: Props = $props(); @@ -80,11 +82,13 @@ function upload() { ajax.addEventListener("abort", abortHandler, false); ajax.open("POST", postUrl); ajax.setRequestHeader("X-FILE-NAME", encodeURIComponent(file.name)); + ajax.setRequestHeader("X-CLAPSHOT-TRANSCODE", transcodePreferred ? "true" : "false"); let upload_cookies = { ...LocalStorageCookies.getAllNonExpired() }; if (mediaFileAddedAction) upload_cookies["media_file_added_action"] = mediaFileAddedAction; upload_cookies["listing_data_json"] = JSON.stringify(listingData); + upload_cookies["transcode_preference"] = transcodePreferred ? "true" : "false"; ajax.setRequestHeader("X-CLAPSHOT-COOKIES", JSON.stringify(upload_cookies)); ajax.send(formdata); diff --git a/client/src/lib/asset_browser/FolderListing.svelte b/client/src/lib/asset_browser/FolderListing.svelte index 25af3ab9..9a746ae7 100644 --- a/client/src/lib/asset_browser/FolderListing.svelte +++ b/client/src/lib/asset_browser/FolderListing.svelte @@ -14,6 +14,7 @@ import { fade } from "svelte/transition"; import { selectedTiles, serverDefinedActions } from "@/stores"; import * as Proto3 from '@clapshot_protobuf/typescript'; +import { t } from "@/i18n"; /* * ⚠️ COMPLEXITY WARNING ⚠️ @@ -178,13 +179,13 @@ function dispatchOpenItem(id: string) { let it = items.find(item => item.id === id); if (it && it.obj.openAction) { let el = document.getElementById("videolist_item__" + id); - if (!el) { alert("UI BUG: item not found"); } else { + if (!el) { alert($t("UI BUG: item not found")); } else { el.classList.add("videolist_item_pump_anim"); setTimeout(() => { el?.classList.remove("videolist_item_pump_anim"); }, 1000); } dispatch("open-item", { item: it.obj, listingData }); } else { - alert("UI BUG: item not found or missing openAction"); + alert($t("UI BUG: item not found or missing openAction")); } } @@ -271,9 +272,9 @@ function handleMouseUp(e: MouseEvent, item: VideoListDefItem) { function hidePopupMenus() { let popupContainer = document.querySelector('#popup-container'); - if (!popupContainer) { alert("UI BUG: popup container missing"); return; } + if (!popupContainer) { alert($t("UI BUG: popup container missing")); return; } for (let child of popupContainer.children as any) { - if (!('hide' in child)) { alert("UI BUG: popup container child missing hide()"); } + if (!('hide' in child)) { alert($t("UI BUG: popup container child missing hide()")); } child.hide(); } } @@ -282,7 +283,7 @@ function hidePopupMenus() { function onContextMenu(e: MouseEvent, item: VideoListDefItem|null) { let popupContainer = document.querySelector('#popup-container'); - if (!popupContainer) { alert("UI BUG: popup container missing"); return; } + if (!popupContainer) { alert($t("UI BUG: popup container missing")); return; } hidePopupMenus(); let actions: Proto3.ActionDef[] = []; @@ -419,7 +420,7 @@ function enterKeyInterceptor(node: HTMLElement) { onrejectdrop={() => dispatch("refresh-listing")} /> {:else} -
Unknown item type
+
{$t("Unknown item type")}
{/if} {/if}
diff --git a/client/src/lib/asset_browser/VideoTile.svelte b/client/src/lib/asset_browser/VideoTile.svelte index 18c3b863..57ad9c79 100644 --- a/client/src/lib/asset_browser/VideoTile.svelte +++ b/client/src/lib/asset_browser/VideoTile.svelte @@ -8,6 +8,7 @@ import {rgbToCssColor, cssVariables} from './utils'; import {latestProgressReports} from '@/stores'; import {slide} from "svelte/transition"; import type { MediaProgressReport } from '@/types'; +import { t } from "@/i18n"; interface Props { @@ -38,7 +39,7 @@ $effect(() => { }); function fmt_date(d: Date | undefined) { - if (!d) return "(no date)"; + if (!d) return $t("(no date)"); return d.toISOString().split('T')[0]; } @@ -63,12 +64,16 @@ function fmt_date(d: Date | undefined) {
+ {:else} +
+ +
{/if} {#if progress !== undefined}
-
{progressMsg || 'Processing...'}
+
{progressMsg || $t("Processing...")}
@@ -116,4 +121,3 @@ function fmt_date(d: Date | undefined) { } - diff --git a/client/src/lib/player_view/CommentInput.svelte b/client/src/lib/player_view/CommentInput.svelte index be8389c7..4d2ec334 100644 --- a/client/src/lib/player_view/CommentInput.svelte +++ b/client/src/lib/player_view/CommentInput.svelte @@ -66,9 +66,9 @@ function onTextChange(e: any) { - {#each ["red", "green", "blue", "cyan", "yellow", "black", "white"] as c} - - {/each} + {#each ["red", "green", "blue", "cyan", "yellow", "black", "white"] as c} + + {/each}
{/if} diff --git a/client/src/lib/player_view/SubtitleCard.svelte b/client/src/lib/player_view/SubtitleCard.svelte index 4dd07bf5..68925955 100644 --- a/client/src/lib/player_view/SubtitleCard.svelte +++ b/client/src/lib/player_view/SubtitleCard.svelte @@ -77,7 +77,7 @@ function handleKeyDown(event: { key: string; }) {
diff --git a/client/src/lib/player_view/VideoPlayer.svelte b/client/src/lib/player_view/VideoPlayer.svelte index bec8bef2..d540ab88 100644 --- a/client/src/lib/player_view/VideoPlayer.svelte +++ b/client/src/lib/player_view/VideoPlayer.svelte @@ -826,23 +826,23 @@ function handlePinClick(id: string) { - - - + + + onTimecodeEdited(e)}/> - FR onFrameEdited(e)}/> + {$t("FR", { context: "frame label", comment: "short label for the frame number input field" })} onFrameEdited(e)}/> {#if !$collabId} + onclick={() => setLoopPoint(true)} title={$t("Set loop start to current frame")} aria-label={$t("Set loop start to current frame")}> + onclick={() => setLoopPoint(false)} title={$t("Set loop end to current frame")} aria-label={$t("Set loop end to current frame")}> {/if} @@ -852,14 +852,14 @@ function handlePinClick(id: string) { {#if ($curVideo?.subtitles?.length ?? 0) > 0} {:else} @@ -887,8 +887,8 @@ function handlePinClick(id: string) { diff --git a/client/src/lib/tools/EDLImport.svelte b/client/src/lib/tools/EDLImport.svelte index 83229cab..2c44e133 100644 --- a/client/src/lib/tools/EDLImport.svelte +++ b/client/src/lib/tools/EDLImport.svelte @@ -106,17 +106,17 @@ const handleAccept = () => {
- + - +
{#if edlEvents.length>0} - + {/if} - +
diff --git a/client/src/lib/tools/comment-export/ExportDialog.svelte b/client/src/lib/tools/comment-export/ExportDialog.svelte index 1b89ad9c..92e615b5 100644 --- a/client/src/lib/tools/comment-export/ExportDialog.svelte +++ b/client/src/lib/tools/comment-export/ExportDialog.svelte @@ -1,6 +1,6 @@ @@ -74,7 +71,16 @@ let formatItems = $derived(exporters.map(e => ({ value: e.id, name: e.name })));
- selectedExporterId = e.currentTarget.value} + class="mt-1 block w-full rounded-lg border border-gray-600 bg-gray-700 p-2.5 text-sm text-white focus:border-blue-500 focus:ring-blue-500" + > + {#each exporters as e} + + {/each} +
@@ -141,15 +147,15 @@ let formatItems = $derived(exporters.map(e => ({ value: e.id, name: e.name })));
{#if groupedComments.length > 0} - + {/if} - +
{#if groupedComments.length > 0}

- {groupedComments.length} comment{groupedComments.length !== 1 ? 's' : ''} will be exported. + {groupedComments.length === 1 ? $t("1 comment will be exported.") : $t("{n} comments will be exported.", { n: groupedComments.length })}

{:else}

{$t("No comments to export.")}

diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts index 4078e747..c663c2cc 100644 --- a/client/src/vite-env.d.ts +++ b/client/src/vite-env.d.ts @@ -1,2 +1,14 @@ /// /// + +declare global { + namespace NodeJS { + interface ProcessEnv { + CLAPSHOT_CLIENT_VERSION: string; + CLAPSHOT_MIN_SERVER_VERSION: string; + CLAPSHOT_MAX_SERVER_VERSION: string; + } + } +} + +export {}; diff --git a/doc/TESTING_GUIDE.md b/doc/TESTING_GUIDE.md index dd40c83d..6b719999 100644 --- a/doc/TESTING_GUIDE.md +++ b/doc/TESTING_GUIDE.md @@ -367,7 +367,7 @@ npm run dev # Build and run complete stack make run-docker -# Access: http://localhost:8080/ +# Access: http://localhost:8081/ ``` ### Testing Workflows diff --git a/i18n/po/fi.po b/i18n/po/fi.po index a0f9302e..ae7c5a06 100644 --- a/i18n/po/fi.po +++ b/i18n/po/fi.po @@ -1,183 +1,205 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-09 20:48+0300\n" +"POT-Creation-Date: 2026-06-25 05:21+0800\n" "Language: fi\n" "Content-Type: text/plain; charset=UTF-8\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/user_session.rs:176 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/user_session.rs:176 msgid "Internal error in authz: {desc}" msgstr "Sisäinen virhe käyttöoikeuksien tarkistuksessa: {desc}" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/user_session.rs:183 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/user_session.rs:183 msgid "Permission denied" msgstr "Ei käyttöoikeutta" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/user_session.rs:217 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/user_session.rs:217 msgid "Permission denied: {desc}" msgstr "Ei käyttöoikeutta: {desc}" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:52 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:52 msgid "No such media file." msgstr "Mediatiedostoa ei löydy." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:111 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:111 msgid "You have no media yet." msgstr "Ei vielä yhtään tiedostoa." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:113 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:113 msgid "All your media files" msgstr "Kaikki mediatiedostosi" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:120 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:120 msgid "Your Media" msgstr "Oma media" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:244 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/i18n.rs:45 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:244 +#: /home/mikesolar/Projects/clapshot/server/src/i18n.rs:45 msgid "{kind} deleted." msgstr "{kind} poistettu." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:246 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:246 msgid "{kind} deleted, but cleanup had errors." msgstr "{kind} poistettu, mutta siivouksessa tapahtui virheitä." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:268 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:268 msgid "Invalid file name (must contain a letter or number)" msgstr "Virheellinen tiedostonimi (täytyy sisältää kirjain tai numero)" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:272 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:272 msgid "Name too long (max 160)" msgstr "Nimi on liian pitkä (enintään 160)" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:278 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:278 msgid "{kind} renamed." msgstr "{kind} nimettiin uudelleen." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:279 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:279 msgid "New name: '{name}'" msgstr "Uusi nimi: '{name}'" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:401 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:401 msgid "Failed to edit comment." msgstr "Kommentin muokkaaminen epäonnistui." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:401 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:401 msgid "No such comment. Cannot edit." msgstr "Kommenttia ei löydy. Ei voida muokata." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:420 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:425 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:435 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:420 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:425 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:435 msgid "Failed to delete comment." msgstr "Kommentin poistaminen epäonnistui." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:420 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:420 msgid "You can only delete your own comments" msgstr "Voit poistaa vain omia kommenttejasi" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:425 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:425 msgid "Comment has replies. Cannot delete." msgstr "Kommenttiin on vastauksia. Ei voida poistaa." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:435 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:435 msgid "No such comment. Cannot delete." msgstr "Kommenttia ei löydy. Ei voida poistaa." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:478 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:495 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:478 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:496 msgid "Failed to add subtitle." msgstr "Tekstityksen lisääminen epäonnistui." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:478 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:478 msgid "Subtitle file already exists: '{filename}'" msgstr "Tekstitystiedosto on jo olemassa: '{filename}'" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:495 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:496 msgid "WebVTT file already exists: '{filename}'" msgstr "WebVTT-tiedosto on jo olemassa: '{filename}'" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:652 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:654 msgid "Failed to join collab session: {error}" msgstr "Yhteisistuntoon liittyminen epäonnistui: {error}" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:691 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:693 msgid "Report rejected: no active collab session." msgstr "Raportti hylätty: ei aktiivista yhteisistuntoa." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:715 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:736 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:717 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:738 msgid "No organizer session." msgstr "Ei organizer-istuntoa." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:777 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:779 msgid "Missing command from client: {request}" msgstr "Asiakkaalta puuttuu komento: {request}" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:822 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:824 msgid "Cmd '{cmd}' failed: {error}" msgstr "Komento '{cmd}' epäonnistui: {error}" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:59 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:59 msgid "Rename item" msgstr "Nimeä kohde uudelleen" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:60 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:60 msgid "Unknown item type in rename action. Please report this bug." msgstr "Tuntematon kohdetyyppi nimeämistoiminnossa. Ilmoita tästä virheestä." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:77 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/i18n.rs:39 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/i18n.rs:40 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:77 +#: /home/mikesolar/Projects/clapshot/server/src/i18n.rs:39 +#: /home/mikesolar/Projects/clapshot/server/src/i18n.rs:40 msgid "Rename" msgstr "Nimeä uudelleen" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:84 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:84 msgid "Rename selected items" msgstr "Nimeä valitut kohteet uudelleen" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:99 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:99 msgid "Are you sure you want to trash this item?" msgstr "Haluatko varmasti siirtää tämän kohteen roskakoriin?" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:100 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:100 msgid "Are you sure you want to trash '{title}'?" msgstr "Haluatko varmasti siirtää kohteen '{title}' roskakoriin?" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:101 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:101 msgid "Are you sure you want to trash folder '{title}' and ALL CONTENTS?" -msgstr "Haluatko varmasti siirtää kansion '{title}' ja KAIKEN SEN SISÄLLÖN roskakoriin?" +msgstr "" +"Haluatko varmasti siirtää kansion '{title}' ja KAIKEN SEN SISÄLLÖN " +"roskakoriin?" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:102 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:102 msgid "Are you sure you want to trash ALL selected items?" msgstr "Haluatko varmasti siirtää KAIKKI valitut kohteet roskakoriin?" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:103 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:103 msgid "Unknown item type in trash action. Please report this bug." msgstr "Tuntematon kohdetyyppi roskakoritoiminnossa. Ilmoita tästä virheestä." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:134 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:134 msgid "Trash" msgstr "Roskakoriin" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:141 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:141 msgid "Trash selected items" msgstr "Siirrä valitut kohteet roskakoriin" -#: client/src/App.svelte:319 +#: client/src/App.svelte:228 +msgid "Comment not found" +msgstr "Kommenttia ei löytynyt" + +#: client/src/App.svelte:269 +msgid "Subtitle not found. See log." +msgstr "Tekstitystä ei löytynyt. Katso loki." + +#: client/src/App.svelte:310 +msgid "Error adding subtitle file. See log." +msgstr "Virhe lisättäessä tekstitystiedostoa. Katso loki." + +#: client/src/App.svelte:320 msgid "Are you sure you want to delete this subtitle?" msgstr "Haluatko varmasti poistaa tämän tekstityksen?" -#: client/src/App.svelte:342 +#: client/src/App.svelte:343 msgid "Invalid time offset: {offset}" msgstr "Virheellinen aikasiirtymä: {offset}" -#: client/src/App.svelte:739 +#: client/src/App.svelte:399 +msgid "Unknown URL parameter: '{key}'" +msgstr "Tuntematon URL-parametri: '{key}'" + +#: client/src/App.svelte:695 +msgid "Client error: {error}" +msgstr "Client-virhe: {error}" + +#: client/src/App.svelte:738 msgid "Server version too old (v{version}). Please update server." msgstr "Palvelimen versio on liian vanha (v{version}). Päivitä palvelin." -#: client/src/App.svelte:746 +#: client/src/App.svelte:745 msgid "" "Client version too old (client v{clientVersion} for server " "v{serverVersion}). Please update client." @@ -185,76 +207,109 @@ msgstr "" "Client-versio on liian vanha (v{clientVersion} vs. palvelimen " "v{serverVersion}). Päivitä versio." -#: client/src/App.svelte:896 +#: client/src/App.svelte:753 +msgid "No user in welcome message" +msgstr "Ei käyttäjää tervetuloviestissä" + +#: client/src/App.svelte:783 client/src/App.svelte:877 +msgid "Clapshot - {title}" +msgstr "Clapshot - {title}" + +#: client/src/App.svelte:787 +msgid "Home" +msgstr "Koti" + +#: client/src/App.svelte:895 msgid "Bad video open request. See log." msgstr "Virheellinen videon avauspyyntö. Katso loki." -#: client/src/App.svelte:1064 +#: client/src/App.svelte:940 +msgid "{user} is controlling" +msgstr "{user} ohjaa" + +#: client/src/App.svelte:981 +msgid "No open action for item. See log." +msgstr "Kohteelle ei ole avaus-toimintoa. Katso loki." + +#: client/src/App.svelte:1021 +msgid "BUG: Unsupported script lang. See log." +msgstr "BUG: Ei-tuettu skriptikieli. Katso loki." + +#: client/src/App.svelte:1032 +msgid "Organizer script error. See log." +msgstr "Organizer-skriptivirhe. Katso loki." + +#: client/src/App.svelte:1063 msgid "Connecting server..." msgstr "Yhdistetään palvelimeen..." -#: client/src/App.svelte:1072 +#: client/src/App.svelte:1071 msgid "View connection errors" msgstr "Näytä yhteysvirheet" -#: client/src/App.svelte:1078 +#: client/src/App.svelte:1077 msgid "See browser JS console for more details." msgstr "Lisätietoja selaimen JS-konsolissa." -#: client/src/App.svelte:1110 +#: client/src/App.svelte:1109 msgid "Sort: date posted" msgstr "Järjestys: lisäyspäivä" -#: client/src/App.svelte:1110 +#: client/src/App.svelte:1109 msgid "Sort: timecode" msgstr "Järjestys: aikakoodi" -#: client/src/App.svelte:1130 +#: client/src/App.svelte:1129 msgid "Subtitles" msgstr "Tekstitykset" -#: client/src/App.svelte:1152 +#: client/src/App.svelte:1130 client/src/lib/player_view/VideoPlayer.svelte:861 +#: client/src/lib/player_view/VideoPlayer.svelte:862 +msgid "Upload subtitles" +msgstr "Lataa tekstitys" + +#: client/src/App.svelte:1151 msgctxt "collab" msgid "Collaborative viewing session active." msgstr "Yhteiskatseluistunto aktiivinen." -#: client/src/App.svelte:1153 +#: client/src/App.svelte:1152 msgctxt "collab" msgid "Session ID is {id}" msgstr "Istunnon tunnus on {id}" -#: client/src/App.svelte:1154 +#: client/src/App.svelte:1153 msgctxt "collab" msgid "Actions like seek, play and draw are mirrored to all participants." msgstr "" "Toiminnot kuten kelaus, toisto ja piirtäminen peilataan kaikille " "osallistujille." -#: client/src/App.svelte:1155 +#: client/src/App.svelte:1154 msgctxt "collab" msgid "To invite people, copy browser URL and send it to them." msgstr "Kutsuaksesi muita, kopioi selaimen osoite ja lähetä se osallistujille." -#: client/src/App.svelte:1156 +#: client/src/App.svelte:1155 msgctxt "collab" msgid "Exit by clicking the green icon in header." msgstr "Poistu napsauttamalla vihreää kuvaketta ylärivillä." #. button to acknowledge -#: client/src/App.svelte:1157 +#: client/src/App.svelte:1156 msgctxt "collab" msgid "Understood" msgstr "Selvä" -#: client/src/App.svelte:1186 +#: client/src/App.svelte:1185 msgid "Drop video, audio and image files here to upload" msgstr "Pudota video-, ääni- ja kuvatiedostoja tähän ladataksesi" -#: client/src/App.svelte:1217 +#: client/src/App.svelte:1216 msgid "Latest messages" msgstr "Viimeisimmät viestit" -#: client/src/lib/NavBar.svelte:70 +#: client/src/lib/NavBar.svelte:74 msgid "" "Basic auth logout failed.\n" "Status code from {url}: {status} (not 401)" @@ -262,7 +317,7 @@ msgstr "" "Basic auth -uloskirjautuminen epäonnistui.\n" "Tilakoodi osoitteesta {url}: {status} (ei 401)" -#: client/src/lib/NavBar.svelte:79 +#: client/src/lib/NavBar.svelte:83 msgid "" "Clapshot Client version {version}\n" "\n" @@ -274,7 +329,7 @@ msgstr "" "Projektin verkkosivut:\n" "https://github.com/elonen/clapshot\n" -#: client/src/lib/NavBar.svelte:88 +#: client/src/lib/NavBar.svelte:92 msgid "" "Link copied to clipboard.\n" "Send it to reviewers who have user accounts here." @@ -282,85 +337,135 @@ msgstr "" "Linkki kopioitiin leikepöydälle.\n" "Lähetä se katsojille, joilla on käyttäjätili järjestelmässä." -#: client/src/lib/NavBar.svelte:140 +#: client/src/lib/NavBar.svelte:138 +msgid "Open menu" +msgstr "Avaa valikko" + +#: client/src/lib/NavBar.svelte:144 msgid "Share to logged in users" msgstr "Jaa kirjautuneille käyttäjille" -#: client/src/lib/NavBar.svelte:142 +#: client/src/lib/NavBar.svelte:146 msgid "Download original" msgstr "Lataa alkuperäinen" -#: client/src/lib/NavBar.svelte:145 +#: client/src/lib/NavBar.svelte:149 msgctxt "collab" msgid "Leave collaborative Session" msgstr "Poistu yhteisistunnosta" -#: client/src/lib/NavBar.svelte:147 +#: client/src/lib/NavBar.svelte:151 msgctxt "collab" msgid "Collaboration is disabled here" msgstr "Yhteisistunto ei käytettävissä" -#: client/src/lib/NavBar.svelte:147 client/src/lib/NavBar.svelte:149 +#: client/src/lib/NavBar.svelte:151 client/src/lib/NavBar.svelte:153 msgctxt "collab" msgid "Start Collaborative Session" msgstr "Aloita yhteisistunto" -#: client/src/lib/NavBar.svelte:153 +#: client/src/lib/NavBar.svelte:157 msgid "Experimental tools" msgstr "Kokeelliset työkalut" -#: client/src/lib/NavBar.svelte:157 client/src/lib/tools/EDLImport.svelte:107 +#: client/src/lib/NavBar.svelte:161 client/src/lib/tools/EDLImport.svelte:107 msgid "Import EDL as Comments" msgstr "Tuo EDL kommentteina" -#: client/src/lib/NavBar.svelte:158 -#: client/src/lib/tools/comment-export/ExportDialog.svelte:72 +#: client/src/lib/NavBar.svelte:162 +#: client/src/lib/tools/comment-export/ExportDialog.svelte:69 msgid "Export Comments" msgstr "Vie kommentit" -#: client/src/lib/NavBar.svelte:190 client/src/lib/NavBar.svelte:191 +#: client/src/lib/NavBar.svelte:177 +#: client/src/lib/asset_browser/VideoTile.svelte:76 +msgid "Processing..." +msgstr "Käsitellään..." + +#: client/src/lib/NavBar.svelte:205 client/src/lib/NavBar.svelte:206 msgctxt "menu" msgid "Language" msgstr "Kieli" -#: client/src/lib/NavBar.svelte:200 client/src/lib/NavBar.svelte:217 +#: client/src/lib/NavBar.svelte:215 client/src/lib/NavBar.svelte:232 msgid "Logout" msgstr "Kirjaudu ulos" #. menu item that opens the 'About this app' dialog -#: client/src/lib/NavBar.svelte:202 +#: client/src/lib/NavBar.svelte:217 msgctxt "menu" msgid "About" msgstr "Tietoja" -#: client/src/lib/NavBar.svelte:218 +#: client/src/lib/NavBar.svelte:233 msgid "Reload page to log in again." msgstr "Lataa sivu uudelleen kirjautuaksesi." -#: client/src/lib/asset_browser/FileUpload.svelte:51 +#: client/src/lib/UserMessage.svelte:21 +msgid "OK" +msgstr "OK" + +#: client/src/lib/UserMessage.svelte:23 +msgid "ERROR" +msgstr "VIRHE" + +#: client/src/lib/UserMessage.svelte:25 +msgid "PROGRESS" +msgstr "EDISTYMINEN" + +#: client/src/lib/asset_browser/FileUpload.svelte:53 msgid "{percent}% uploaded... please wait" msgstr "{percent}% ladattu... odota hetki" -#: client/src/lib/asset_browser/FileUpload.svelte:61 +#: client/src/lib/asset_browser/FileUpload.svelte:63 msgid "Upload Failed" msgstr "Lataus epäonnistui" -#: client/src/lib/asset_browser/FileUpload.svelte:66 +#: client/src/lib/asset_browser/FileUpload.svelte:68 msgid "Upload Aborted" msgstr "Lataus keskeytetty" -#: client/src/lib/asset_browser/FileUpload.svelte:76 +#: client/src/lib/asset_browser/FileUpload.svelte:78 msgid "Uploading: {filename}..." msgstr "Ladataan: {filename}..." -#: client/src/lib/asset_browser/FileUpload.svelte:101 +#: client/src/lib/asset_browser/FileUpload.svelte:105 msgid "Drop rejected. Only video, audio and image files are allowed." msgstr "Tiedostotyyppiä ei tueta. Sallitut: video, kuva ja ääni." +#: client/src/lib/asset_browser/FolderListing.svelte:182 +msgid "UI BUG: item not found" +msgstr "UI-BUG: kohdetta ei löytynyt" + +#: client/src/lib/asset_browser/FolderListing.svelte:188 +msgid "UI BUG: item not found or missing openAction" +msgstr "UI-BUG: kohdetta ei löytynyt tai siltä puuttuu avaus-toiminto" + +#: client/src/lib/asset_browser/FolderListing.svelte:275 +#: client/src/lib/asset_browser/FolderListing.svelte:286 +msgid "UI BUG: popup container missing" +msgstr "UI-BUG: ponnahdusvalikon säiliö puuttuu" + +#: client/src/lib/asset_browser/FolderListing.svelte:277 +msgid "UI BUG: popup container child missing hide()" +msgstr "UI-BUG: ponnahdusvalikon lapselta puuttuu hide()" + +#: client/src/lib/asset_browser/FolderListing.svelte:423 +msgid "Unknown item type" +msgstr "Tuntematon kohdetyyppi" + #: client/src/lib/asset_browser/FolderTile.svelte:61 msgid "This target accepts media — folders can’t be dropped here." msgstr "Tämä kohde hyväksyy vain mediaa — ei kansioita." +#: client/src/lib/asset_browser/VideoTile.svelte:42 +msgid "(no date)" +msgstr "(ei päivämäärää)" + +#: client/src/lib/asset_browser/VideoTile.svelte:69 +msgid "video icon" +msgstr "video-ikoni" + #: client/src/lib/player_view/CommentCard.svelte:44 msgid "Delete comment?" msgstr "Poista kommentti?" @@ -411,6 +516,10 @@ msgctxt "comment" msgid "Redo" msgstr "Tee uudelleen" +#: client/src/lib/player_view/CommentInput.svelte:70 +msgid "Select {color} color" +msgstr "Valitse {color} väri" + #: client/src/lib/player_view/CommentInput.svelte:80 msgid "Add a comment - at current time..." msgstr "Lisää kommentti (nykyiseen kohtaan)..." @@ -446,6 +555,10 @@ msgstr "Otsikko" msgid "Language code" msgstr "Kielikoodi" +#: client/src/lib/player_view/SubtitleCard.svelte:80 +msgid "ISO 639 language codes information" +msgstr "ISO 639 -kielikoodien tiedot" + #: client/src/lib/player_view/SubtitleCard.svelte:85 msgid "Time offset (sec)" msgstr "Aikasiirtymä (sek)" @@ -483,27 +596,100 @@ msgstr "Videon lataus kesken? Piirtäminen ei käytössä." msgid "Failed to show image." msgstr "Kuvan näyttäminen epäonnistui." +#: client/src/lib/player_view/VideoPlayer.svelte:829 +msgid "Step backwards" +msgstr "Kelaa taaksepäin" + +#: client/src/lib/player_view/VideoPlayer.svelte:830 +msgid "Play/Pause" +msgstr "Toista/Pysäytä" + +#: client/src/lib/player_view/VideoPlayer.svelte:831 +msgid "Step forwards" +msgstr "Kelaa eteenpäin" + +#. short label for the frame number input field +#: client/src/lib/player_view/VideoPlayer.svelte:836 +msgctxt "frame label" +msgid "FR" +msgstr "KU" + +#: client/src/lib/player_view/VideoPlayer.svelte:843 +msgid "Set loop start to current frame" +msgstr "Aseta silmukan alku nykyiseen kehykseen" + +#: client/src/lib/player_view/VideoPlayer.svelte:845 +msgid "Set loop end to current frame" +msgstr "Aseta silmukan loppu nykyiseen kehykseen" + +#: client/src/lib/player_view/VideoPlayer.svelte:855 +#: client/src/lib/player_view/VideoPlayer.svelte:856 +msgid "Toggle closed captioning" +msgstr "Vaihda tekstitystä päälle/pois" + +#: client/src/lib/player_view/VideoPlayer.svelte:876 +msgid "Mute audio" +msgstr "Mykistä ääni" + +#: client/src/lib/player_view/VideoPlayer.svelte:876 +msgid "Unmute audio" +msgstr "Palauta ääni" + +#: client/src/lib/player_view/VideoPlayer.svelte:890 +msgid "Toggle fullscreen (f)" +msgstr "Vaihda koko näyttöön (f)" + +#: client/src/lib/player_view/VideoPlayer.svelte:891 +msgid "Toggle fullscreen" +msgstr "Vaihda koko näyttöön" + #: client/src/lib/tools/EDLImport.svelte:86 msgid "No time spans found when parsing EDL." msgstr "EDL:ää luettaessa ei löytynyt aikavälejä." +#: client/src/lib/tools/EDLImport.svelte:109 +msgid "Upload EDL" +msgstr "Lataa EDL" + +#: client/src/lib/tools/EDLImport.svelte:111 +#: client/src/lib/tools/comment-export/ExportDialog.svelte:88 +msgid "Frame rate" +msgstr "Kuvataajuus (FPS)" + +#: client/src/lib/tools/EDLImport.svelte:117 +msgid "Add as comments" +msgstr "Lisää kommentteina" + +#: client/src/lib/tools/EDLImport.svelte:119 +#: client/src/lib/tools/comment-export/ExportDialog.svelte:152 +msgid "Cancel" +msgstr "Peruuta" + #: client/src/lib/tools/EDLImport.svelte:124 msgid "Parsed spans" msgstr "Luetut aikavälit" -#: client/src/lib/tools/comment-export/ExportDialog.svelte:76 +#: client/src/lib/tools/comment-export/ExportDialog.svelte:73 msgid "Export format" msgstr "Vientimuoto" -#: client/src/lib/tools/comment-export/ExportDialog.svelte:82 -msgid "Frame rate" -msgstr "Kuvataajuus (FPS)" - -#: client/src/lib/tools/comment-export/ExportDialog.svelte:89 +#: client/src/lib/tools/comment-export/ExportDialog.svelte:95 msgid "Format options" msgstr "Muotoasetukset" -#: client/src/lib/tools/comment-export/ExportDialog.svelte:155 +#: client/src/lib/tools/comment-export/ExportDialog.svelte:150 +msgid "Export" +msgstr "Vie" + +#: client/src/lib/tools/comment-export/ExportDialog.svelte:158 +msgid "1 comment will be exported." +msgstr "1 kommentti viedään." + +#: client/src/lib/tools/comment-export/ExportDialog.svelte:158 +msgid "{n} comments will be exported." +msgstr "{n} kommenttia viedään." + +#: client/src/lib/tools/comment-export/ExportDialog.svelte:161 msgid "No comments to export." msgstr "Ei vietäviä kommentteja." @@ -620,19 +806,7 @@ msgstr "Ei valittua kansiota, jonka jako peruutetaan" msgid "Are you sure you want to revoke the shared link for this folder?" msgstr "Haluatko varmasti peruuttaa tämän kansion jakolinkin?" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:135 -msgid "Copy URL" -msgstr "Kopioi linkki" - -#: organizer/basic_folders/organizer/helpers/actiondefs.py:138 -msgid "Copy the shared link to clipboard" -msgstr "Kopioi jakolinkki leikepöydälle" - -#: organizer/basic_folders/organizer/helpers/actiondefs.py:148 -msgid "No shared link available for this folder" -msgstr "Tälle kansiolle ei ole saatavilla jakolinkkiä" - -#: organizer/basic_folders/organizer/helpers/actiondefs.py:157 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:136 msgid "" "Shared link copied to clipboard!\n" "\n" @@ -646,81 +820,81 @@ msgstr "" "hetkellä oleviin tiedostoihin ja antaa vastaanottajalle käytännössä PYSYVÄN " "pääsyn niihin, vaikka peruuttaisit kansion jaon myöhemmin." -#: organizer/basic_folders/organizer/helpers/actiondefs.py:159 -#: organizer/basic_folders/organizer/helpers/actiondefs.py:162 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:141 +msgid "Copy URL" +msgstr "Kopioi linkki" + +#: organizer/basic_folders/organizer/helpers/actiondefs.py:144 +msgid "Copy the shared link to clipboard" +msgstr "Kopioi jakolinkki leikepöydälle" + +#: organizer/basic_folders/organizer/helpers/actiondefs.py:154 +msgid "No shared link available for this folder" +msgstr "Tälle kansiolle ei ole saatavilla jakolinkkiä" + +#: organizer/basic_folders/organizer/helpers/actiondefs.py:165 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:168 msgid "Copy this shared link:" msgstr "Kopioi tämä jakolinkki:" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:170 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:176 msgid "Into Version Set" msgstr "Muunna versiosarjaksi" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:173 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:179 msgid "Turn this folder into a version set (revisions of one media file)" msgstr "Muuta tämä kansio versiosarjaksi (yhden mediatiedoston versiot)" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:179 -#: organizer/basic_folders/organizer/helpers/actiondefs.py:196 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:185 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:202 msgid "No folder selected" msgstr "Ei valittua kansiota" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:187 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:193 msgid "Into Normal Folder" msgstr "Muunna tavalliseksi kansioksi" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:190 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:196 msgid "Turn this version set back into a normal folder" msgstr "Muuta tämä versiosarja tavalliseksi kansioksi" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:204 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:210 msgid "Make Versioned" msgstr "Ryhmittele versiosarjaksi" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:207 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:213 msgid "Group the selected media files into a new version set" msgstr "Ryhmittele valitut mediatiedostot uudeksi versiosarjaksi" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:213 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:219 msgid "No media files selected" msgstr "Ei valittuja mediatiedostoja" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:221 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:227 msgid "Manage Versions" msgstr "Hallitse versioita" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:224 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:230 msgid "Open the version set to manage its versions" msgstr "Avaa versiosarja hallitaksesi sen versioita" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:230 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:236 msgid "No version set selected" msgstr "Ei valittua versiosarjaa" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:239 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:245 msgid "Set Active Version" msgstr "Aseta aktiiviseksi versioksi" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:242 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:248 msgid "Make this version the active one for the set" msgstr "Aseta tämä versio sarjan aktiiviseksi versioksi" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:250 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:256 msgid "Cannot set active version (missing ids)" msgstr "Aktiivista versiota ei voi asettaa (tunnisteet puuttuvat)" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:259 -msgid "Delete user" -msgstr "Poista käyttäjä" - -#: organizer/basic_folders/organizer/helpers/actiondefs.py:262 -msgid "Delete user if they have no content (only empty root folder)" -msgstr "Poista käyttäjä, jos hänellä ei ole sisältöä (vain tyhjä juurikansio)" - -#: organizer/basic_folders/organizer/helpers/actiondefs.py:269 -msgid "No user folder selected for cleanup" -msgstr "Ei valittua käyttäjäkansiota siivottavaksi" - -#: organizer/basic_folders/organizer/helpers/actiondefs.py:272 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:266 msgid "" "This will delete the user if they have no media files and only an empty root " "folder.\n" @@ -738,6 +912,18 @@ msgstr "" "\n" "Oletko varma?" +#: organizer/basic_folders/organizer/helpers/actiondefs.py:271 +msgid "Delete user" +msgstr "Poista käyttäjä" + +#: organizer/basic_folders/organizer/helpers/actiondefs.py:274 +msgid "Delete user if they have no content (only empty root folder)" +msgstr "Poista käyttäjä, jos hänellä ei ole sisältöä (vain tyhjä juurikansio)" + +#: organizer/basic_folders/organizer/helpers/actiondefs.py:281 +msgid "No user folder selected for cleanup" +msgstr "Ei valittua käyttäjäkansiota siivottavaksi" + #: organizer/basic_folders/organizer/helpers/folders.py:212 msgid "Cannot fetch contents of another user's folder" msgstr "Toisen käyttäjän kansion sisältöä ei voi hakea" diff --git a/i18n/po/fr.po b/i18n/po/fr.po index d35a526c..fa2d1353 100644 --- a/i18n/po/fr.po +++ b/i18n/po/fr.po @@ -1,185 +1,210 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-09 20:48+0300\n" +"POT-Creation-Date: 2026-06-25 05:21+0800\n" "Language: fr\n" "Content-Type: text/plain; charset=UTF-8\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/user_session.rs:176 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/user_session.rs:176 msgid "Internal error in authz: {desc}" msgstr "Erreur interne d'autorisation : {desc}" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/user_session.rs:183 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/user_session.rs:183 msgid "Permission denied" msgstr "Permission refusée" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/user_session.rs:217 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/user_session.rs:217 msgid "Permission denied: {desc}" msgstr "Permission refusée : {desc}" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:52 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:52 msgid "No such media file." msgstr "Fichier multimédia introuvable." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:111 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:111 msgid "You have no media yet." msgstr "Vous n'avez pas encore de média." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:113 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:113 msgid "All your media files" msgstr "Tous vos fichiers multimédias" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:120 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:120 msgid "Your Media" msgstr "Vos médias" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:244 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/i18n.rs:45 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:244 +#: /home/mikesolar/Projects/clapshot/server/src/i18n.rs:45 msgid "{kind} deleted." msgstr "{kind} supprimé." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:246 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:246 msgid "{kind} deleted, but cleanup had errors." msgstr "{kind} supprimé, mais le nettoyage a rencontré des erreurs." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:268 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:268 msgid "Invalid file name (must contain a letter or number)" msgstr "Nom de fichier invalide (doit contenir une lettre ou un chiffre)" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:272 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:272 msgid "Name too long (max 160)" msgstr "Nom trop long (160 maximum)" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:278 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:278 msgid "{kind} renamed." msgstr "{kind} renommé." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:279 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:279 msgid "New name: '{name}'" msgstr "Nouveau nom : '{name}'" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:401 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:401 msgid "Failed to edit comment." msgstr "Échec de la modification du commentaire." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:401 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:401 msgid "No such comment. Cannot edit." msgstr "Commentaire introuvable. Modification impossible." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:420 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:425 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:435 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:420 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:425 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:435 msgid "Failed to delete comment." msgstr "Échec de la suppression du commentaire." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:420 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:420 msgid "You can only delete your own comments" msgstr "Vous ne pouvez supprimer que vos propres commentaires" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:425 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:425 msgid "Comment has replies. Cannot delete." msgstr "Le commentaire a des réponses. Suppression impossible." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:435 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:435 msgid "No such comment. Cannot delete." msgstr "Commentaire introuvable. Suppression impossible." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:478 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:495 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:478 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:496 msgid "Failed to add subtitle." msgstr "Échec de l'ajout du sous-titre." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:478 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:478 msgid "Subtitle file already exists: '{filename}'" msgstr "Le fichier de sous-titres existe déjà : '{filename}'" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:495 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:496 msgid "WebVTT file already exists: '{filename}'" msgstr "Le fichier WebVTT existe déjà : '{filename}'" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:652 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:654 msgid "Failed to join collab session: {error}" msgstr "Échec de la connexion à la session collaborative : {error}" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:691 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:693 msgid "Report rejected: no active collab session." msgstr "Rapport rejeté : aucune session collaborative active." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:715 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:736 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:717 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:738 msgid "No organizer session." msgstr "Aucune session d'organiseur." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:777 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:779 msgid "Missing command from client: {request}" msgstr "Commande manquante du client : {request}" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:822 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:824 msgid "Cmd '{cmd}' failed: {error}" msgstr "Échec de la commande '{cmd}' : {error}" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:59 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:59 msgid "Rename item" msgstr "Renommer l'élément" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:60 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:60 msgid "Unknown item type in rename action. Please report this bug." -msgstr "Type d'élément inconnu dans l'action renommer. Veuillez signaler ce bug." +msgstr "" +"Type d'élément inconnu dans l'action renommer. Veuillez signaler ce bug." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:77 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/i18n.rs:39 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/i18n.rs:40 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:77 +#: /home/mikesolar/Projects/clapshot/server/src/i18n.rs:39 +#: /home/mikesolar/Projects/clapshot/server/src/i18n.rs:40 msgid "Rename" msgstr "Renommer" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:84 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:84 msgid "Rename selected items" msgstr "Renommer les éléments sélectionnés" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:99 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:99 msgid "Are you sure you want to trash this item?" msgstr "Voulez-vous vraiment mettre cet élément à la corbeille ?" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:100 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:100 msgid "Are you sure you want to trash '{title}'?" msgstr "Voulez-vous vraiment mettre '{title}' à la corbeille ?" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:101 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:101 msgid "Are you sure you want to trash folder '{title}' and ALL CONTENTS?" -msgstr "Voulez-vous vraiment mettre le dossier '{title}' et TOUT SON CONTENU à la corbeille ?" +msgstr "" +"Voulez-vous vraiment mettre le dossier '{title}' et TOUT SON CONTENU à la " +"corbeille ?" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:102 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:102 msgid "Are you sure you want to trash ALL selected items?" -msgstr "Voulez-vous vraiment mettre TOUS les éléments sélectionnés à la corbeille ?" +msgstr "" +"Voulez-vous vraiment mettre TOUS les éléments sélectionnés à la corbeille ?" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:103 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:103 msgid "Unknown item type in trash action. Please report this bug." -msgstr "Type d'élément inconnu dans l'action corbeille. Veuillez signaler ce bug." +msgstr "" +"Type d'élément inconnu dans l'action corbeille. Veuillez signaler ce bug." -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:134 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:134 msgid "Trash" msgstr "Corbeille" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:141 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:141 msgid "Trash selected items" msgstr "Mettre les éléments sélectionnés à la corbeille" -#: client/src/App.svelte:319 +#: client/src/App.svelte:228 +msgid "Comment not found" +msgstr "" + +#: client/src/App.svelte:269 +msgid "Subtitle not found. See log." +msgstr "" + +#: client/src/App.svelte:310 +msgid "Error adding subtitle file. See log." +msgstr "" + +#: client/src/App.svelte:320 msgid "Are you sure you want to delete this subtitle?" msgstr "Voulez-vous vraiment supprimer ce sous-titre ?" -#: client/src/App.svelte:342 +#: client/src/App.svelte:343 msgid "Invalid time offset: {offset}" msgstr "Décalage temporel invalide : {offset}" -#: client/src/App.svelte:739 +#: client/src/App.svelte:399 +msgid "Unknown URL parameter: '{key}'" +msgstr "" + +#: client/src/App.svelte:695 +msgid "Client error: {error}" +msgstr "" + +#: client/src/App.svelte:738 msgid "Server version too old (v{version}). Please update server." msgstr "" "Version du serveur trop ancienne (v{version}). Veuillez mettre à jour le " "serveur." -#: client/src/App.svelte:746 +#: client/src/App.svelte:745 msgid "" "Client version too old (client v{clientVersion} for server " "v{serverVersion}). Please update client." @@ -187,77 +212,112 @@ msgstr "" "Version du client trop ancienne (client v{clientVersion} pour le serveur " "v{serverVersion}). Veuillez mettre à jour le client." -#: client/src/App.svelte:896 +#: client/src/App.svelte:753 +msgid "No user in welcome message" +msgstr "" + +#: client/src/App.svelte:783 client/src/App.svelte:877 +msgid "Clapshot - {title}" +msgstr "" + +#: client/src/App.svelte:787 +#, fuzzy +msgid "Home" +msgstr "Accueil" + +#: client/src/App.svelte:895 msgid "Bad video open request. See log." msgstr "Requête d'ouverture vidéo incorrecte. Consultez le journal." -#: client/src/App.svelte:1064 +#: client/src/App.svelte:940 +msgid "{user} is controlling" +msgstr "" + +#: client/src/App.svelte:981 +msgid "No open action for item. See log." +msgstr "" + +#: client/src/App.svelte:1021 +msgid "BUG: Unsupported script lang. See log." +msgstr "" + +#: client/src/App.svelte:1032 +msgid "Organizer script error. See log." +msgstr "" + +#: client/src/App.svelte:1063 msgid "Connecting server..." msgstr "Connexion au serveur..." -#: client/src/App.svelte:1072 +#: client/src/App.svelte:1071 msgid "View connection errors" msgstr "Voir les erreurs de connexion" -#: client/src/App.svelte:1078 +#: client/src/App.svelte:1077 msgid "See browser JS console for more details." msgstr "Consultez la console JS du navigateur pour plus de détails." -#: client/src/App.svelte:1110 +#: client/src/App.svelte:1109 msgid "Sort: date posted" msgstr "Tri : date de publication" -#: client/src/App.svelte:1110 +#: client/src/App.svelte:1109 msgid "Sort: timecode" msgstr "Tri : timecode" -#: client/src/App.svelte:1130 +#: client/src/App.svelte:1129 msgid "Subtitles" msgstr "Sous-titres" -#: client/src/App.svelte:1152 +#: client/src/App.svelte:1130 client/src/lib/player_view/VideoPlayer.svelte:861 +#: client/src/lib/player_view/VideoPlayer.svelte:862 +#, fuzzy +msgid "Upload subtitles" +msgstr "Modifier le sous-titre" + +#: client/src/App.svelte:1151 msgctxt "collab" msgid "Collaborative viewing session active." msgstr "Session de visionnage collaboratif active." -#: client/src/App.svelte:1153 +#: client/src/App.svelte:1152 msgctxt "collab" msgid "Session ID is {id}" msgstr "Identifiant de session : {id}" -#: client/src/App.svelte:1154 +#: client/src/App.svelte:1153 msgctxt "collab" msgid "Actions like seek, play and draw are mirrored to all participants." msgstr "" "Les actions comme le déplacement, la lecture et le dessin sont synchronisées " "avec tous les participants." -#: client/src/App.svelte:1155 +#: client/src/App.svelte:1154 msgctxt "collab" msgid "To invite people, copy browser URL and send it to them." msgstr "" "Pour inviter des personnes, copiez l'URL du navigateur et envoyez-la leur." -#: client/src/App.svelte:1156 +#: client/src/App.svelte:1155 msgctxt "collab" msgid "Exit by clicking the green icon in header." msgstr "Quittez en cliquant sur l'icône verte dans l'en-tête." #. button to acknowledge -#: client/src/App.svelte:1157 +#: client/src/App.svelte:1156 msgctxt "collab" msgid "Understood" msgstr "Compris" -#: client/src/App.svelte:1186 +#: client/src/App.svelte:1185 msgid "Drop video, audio and image files here to upload" msgstr "Déposez ici des fichiers vidéo, audio et image pour les téléverser" -#: client/src/App.svelte:1217 +#: client/src/App.svelte:1216 msgid "Latest messages" msgstr "Derniers messages" -#: client/src/lib/NavBar.svelte:70 +#: client/src/lib/NavBar.svelte:74 msgid "" "Basic auth logout failed.\n" "Status code from {url}: {status} (not 401)" @@ -265,7 +325,7 @@ msgstr "" "Échec de la déconnexion (authentification de base).\n" "Code de statut de {url} : {status} (pas 401)" -#: client/src/lib/NavBar.svelte:79 +#: client/src/lib/NavBar.svelte:83 msgid "" "Clapshot Client version {version}\n" "\n" @@ -277,7 +337,7 @@ msgstr "" "Visitez la page du projet à :\n" "https://github.com/elonen/clapshot\n" -#: client/src/lib/NavBar.svelte:88 +#: client/src/lib/NavBar.svelte:92 msgid "" "Link copied to clipboard.\n" "Send it to reviewers who have user accounts here." @@ -285,86 +345,136 @@ msgstr "" "Lien copié dans le presse-papiers.\n" "Envoyez-le aux relecteurs qui possèdent un compte ici." -#: client/src/lib/NavBar.svelte:140 +#: client/src/lib/NavBar.svelte:138 +msgid "Open menu" +msgstr "" + +#: client/src/lib/NavBar.svelte:144 msgid "Share to logged in users" msgstr "Partager avec les utilisateurs connectés" -#: client/src/lib/NavBar.svelte:142 +#: client/src/lib/NavBar.svelte:146 msgid "Download original" msgstr "Télécharger l'original" -#: client/src/lib/NavBar.svelte:145 +#: client/src/lib/NavBar.svelte:149 msgctxt "collab" msgid "Leave collaborative Session" msgstr "Quitter la session collaborative" -#: client/src/lib/NavBar.svelte:147 +#: client/src/lib/NavBar.svelte:151 msgctxt "collab" msgid "Collaboration is disabled here" msgstr "La collaboration est désactivée ici" -#: client/src/lib/NavBar.svelte:147 client/src/lib/NavBar.svelte:149 +#: client/src/lib/NavBar.svelte:151 client/src/lib/NavBar.svelte:153 msgctxt "collab" msgid "Start Collaborative Session" msgstr "Démarrer une session collaborative" -#: client/src/lib/NavBar.svelte:153 +#: client/src/lib/NavBar.svelte:157 msgid "Experimental tools" msgstr "Outils expérimentaux" -#: client/src/lib/NavBar.svelte:157 client/src/lib/tools/EDLImport.svelte:107 +#: client/src/lib/NavBar.svelte:161 client/src/lib/tools/EDLImport.svelte:107 msgid "Import EDL as Comments" msgstr "Importer EDL comme commentaires" -#: client/src/lib/NavBar.svelte:158 -#: client/src/lib/tools/comment-export/ExportDialog.svelte:72 +#: client/src/lib/NavBar.svelte:162 +#: client/src/lib/tools/comment-export/ExportDialog.svelte:69 msgid "Export Comments" msgstr "Exporter les commentaires" -#: client/src/lib/NavBar.svelte:190 client/src/lib/NavBar.svelte:191 +#: client/src/lib/NavBar.svelte:177 +#: client/src/lib/asset_browser/VideoTile.svelte:76 +msgid "Processing..." +msgstr "" + +#: client/src/lib/NavBar.svelte:205 client/src/lib/NavBar.svelte:206 msgctxt "menu" msgid "Language" msgstr "Langue" -#: client/src/lib/NavBar.svelte:200 client/src/lib/NavBar.svelte:217 +#: client/src/lib/NavBar.svelte:215 client/src/lib/NavBar.svelte:232 msgid "Logout" msgstr "Déconnexion" #. menu item that opens the 'About this app' dialog -#: client/src/lib/NavBar.svelte:202 +#: client/src/lib/NavBar.svelte:217 msgctxt "menu" msgid "About" msgstr "À propos" -#: client/src/lib/NavBar.svelte:218 +#: client/src/lib/NavBar.svelte:233 msgid "Reload page to log in again." msgstr "Rechargez la page pour vous reconnecter." -#: client/src/lib/asset_browser/FileUpload.svelte:51 +#: client/src/lib/UserMessage.svelte:21 +msgid "OK" +msgstr "" + +#: client/src/lib/UserMessage.svelte:23 +msgid "ERROR" +msgstr "" + +#: client/src/lib/UserMessage.svelte:25 +msgid "PROGRESS" +msgstr "" + +#: client/src/lib/asset_browser/FileUpload.svelte:53 msgid "{percent}% uploaded... please wait" msgstr "{percent}% téléversé... veuillez patienter" -#: client/src/lib/asset_browser/FileUpload.svelte:61 +#: client/src/lib/asset_browser/FileUpload.svelte:63 msgid "Upload Failed" msgstr "Échec du téléversement" -#: client/src/lib/asset_browser/FileUpload.svelte:66 +#: client/src/lib/asset_browser/FileUpload.svelte:68 msgid "Upload Aborted" msgstr "Téléversement annulé" -#: client/src/lib/asset_browser/FileUpload.svelte:76 +#: client/src/lib/asset_browser/FileUpload.svelte:78 msgid "Uploading: {filename}..." msgstr "Téléversement : {filename}..." -#: client/src/lib/asset_browser/FileUpload.svelte:101 +#: client/src/lib/asset_browser/FileUpload.svelte:105 msgid "Drop rejected. Only video, audio and image files are allowed." msgstr "Dépôt refusé. Seuls les fichiers vidéo, audio et image sont acceptés." +#: client/src/lib/asset_browser/FolderListing.svelte:182 +msgid "UI BUG: item not found" +msgstr "" + +#: client/src/lib/asset_browser/FolderListing.svelte:188 +msgid "UI BUG: item not found or missing openAction" +msgstr "" + +#: client/src/lib/asset_browser/FolderListing.svelte:275 +#: client/src/lib/asset_browser/FolderListing.svelte:286 +msgid "UI BUG: popup container missing" +msgstr "" + +#: client/src/lib/asset_browser/FolderListing.svelte:277 +msgid "UI BUG: popup container child missing hide()" +msgstr "" + +#: client/src/lib/asset_browser/FolderListing.svelte:423 +msgid "Unknown item type" +msgstr "" + #: client/src/lib/asset_browser/FolderTile.svelte:61 msgid "This target accepts media — folders can’t be dropped here." msgstr "" "Cette cible accepte des médias — les dossiers ne peuvent pas y être déposés." +#: client/src/lib/asset_browser/VideoTile.svelte:42 +msgid "(no date)" +msgstr "" + +#: client/src/lib/asset_browser/VideoTile.svelte:69 +msgid "video icon" +msgstr "" + #: client/src/lib/player_view/CommentCard.svelte:44 msgid "Delete comment?" msgstr "Supprimer le commentaire ?" @@ -415,6 +525,10 @@ msgctxt "comment" msgid "Redo" msgstr "Rétablir" +#: client/src/lib/player_view/CommentInput.svelte:70 +msgid "Select {color} color" +msgstr "" + #: client/src/lib/player_view/CommentInput.svelte:80 msgid "Add a comment - at current time..." msgstr "Ajouter un commentaire — à l'instant actuel..." @@ -450,6 +564,10 @@ msgstr "Titre" msgid "Language code" msgstr "Code de langue" +#: client/src/lib/player_view/SubtitleCard.svelte:80 +msgid "ISO 639 language codes information" +msgstr "" + #: client/src/lib/player_view/SubtitleCard.svelte:85 msgid "Time offset (sec)" msgstr "Décalage temporel (sec)" @@ -487,27 +605,105 @@ msgstr "Chargement de la vidéo non terminé ? Impossible d'activer le dessin." msgid "Failed to show image." msgstr "Échec de l'affichage de l'image." +#: client/src/lib/player_view/VideoPlayer.svelte:829 +msgid "Step backwards" +msgstr "" + +#: client/src/lib/player_view/VideoPlayer.svelte:830 +msgid "Play/Pause" +msgstr "" + +#: client/src/lib/player_view/VideoPlayer.svelte:831 +msgid "Step forwards" +msgstr "" + +#. short label for the frame number input field +#: client/src/lib/player_view/VideoPlayer.svelte:836 +msgctxt "frame label" +msgid "FR" +msgstr "" + +#: client/src/lib/player_view/VideoPlayer.svelte:843 +msgid "Set loop start to current frame" +msgstr "" + +#: client/src/lib/player_view/VideoPlayer.svelte:845 +msgid "Set loop end to current frame" +msgstr "" + +#: client/src/lib/player_view/VideoPlayer.svelte:855 +#: client/src/lib/player_view/VideoPlayer.svelte:856 +msgid "Toggle closed captioning" +msgstr "" + +#: client/src/lib/player_view/VideoPlayer.svelte:876 +msgid "Mute audio" +msgstr "" + +#: client/src/lib/player_view/VideoPlayer.svelte:876 +msgid "Unmute audio" +msgstr "" + +#: client/src/lib/player_view/VideoPlayer.svelte:890 +msgid "Toggle fullscreen (f)" +msgstr "" + +#: client/src/lib/player_view/VideoPlayer.svelte:891 +msgid "Toggle fullscreen" +msgstr "" + #: client/src/lib/tools/EDLImport.svelte:86 msgid "No time spans found when parsing EDL." msgstr "Aucune plage temporelle trouvée lors de l'analyse de l'EDL." +#: client/src/lib/tools/EDLImport.svelte:109 +#, fuzzy +msgid "Upload EDL" +msgstr "Échec du téléversement" + +#: client/src/lib/tools/EDLImport.svelte:111 +#: client/src/lib/tools/comment-export/ExportDialog.svelte:88 +msgid "Frame rate" +msgstr "Fréquence d'images" + +#: client/src/lib/tools/EDLImport.svelte:117 +#, fuzzy +msgid "Add as comments" +msgstr "Ajouter un commentaire..." + +#: client/src/lib/tools/EDLImport.svelte:119 +#: client/src/lib/tools/comment-export/ExportDialog.svelte:152 +msgid "Cancel" +msgstr "" + #: client/src/lib/tools/EDLImport.svelte:124 msgid "Parsed spans" msgstr "Plages analysées" -#: client/src/lib/tools/comment-export/ExportDialog.svelte:76 +#: client/src/lib/tools/comment-export/ExportDialog.svelte:73 msgid "Export format" msgstr "Format d'exportation" -#: client/src/lib/tools/comment-export/ExportDialog.svelte:82 -msgid "Frame rate" -msgstr "Fréquence d'images" - -#: client/src/lib/tools/comment-export/ExportDialog.svelte:89 +#: client/src/lib/tools/comment-export/ExportDialog.svelte:95 msgid "Format options" msgstr "Options de format" -#: client/src/lib/tools/comment-export/ExportDialog.svelte:155 +#: client/src/lib/tools/comment-export/ExportDialog.svelte:150 +#, fuzzy +msgid "Export" +msgstr "Format d'exportation" + +#: client/src/lib/tools/comment-export/ExportDialog.svelte:158 +#, fuzzy +msgid "1 comment will be exported." +msgstr "Aucun commentaire à exporter." + +#: client/src/lib/tools/comment-export/ExportDialog.svelte:158 +#, fuzzy +msgid "{n} comments will be exported." +msgstr "Aucun commentaire à exporter." + +#: client/src/lib/tools/comment-export/ExportDialog.svelte:161 msgid "No comments to export." msgstr "Aucun commentaire à exporter." @@ -632,19 +828,7 @@ msgstr "Aucun dossier sélectionné pour arrêter le partage" msgid "Are you sure you want to revoke the shared link for this folder?" msgstr "Voulez-vous vraiment révoquer le lien partagé de ce dossier ?" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:135 -msgid "Copy URL" -msgstr "Copier l'URL" - -#: organizer/basic_folders/organizer/helpers/actiondefs.py:138 -msgid "Copy the shared link to clipboard" -msgstr "Copier le lien partagé dans le presse-papiers" - -#: organizer/basic_folders/organizer/helpers/actiondefs.py:148 -msgid "No shared link available for this folder" -msgstr "Aucun lien partagé disponible pour ce dossier" - -#: organizer/basic_folders/organizer/helpers/actiondefs.py:157 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:136 msgid "" "Shared link copied to clipboard!\n" "\n" @@ -658,87 +842,85 @@ msgstr "" "fichiers qu'il contient actuellement, donnant ainsi au destinataire un accès " "PERMANENT à ceux-ci, même si vous arrêtez le partage du dossier plus tard." -#: organizer/basic_folders/organizer/helpers/actiondefs.py:159 -#: organizer/basic_folders/organizer/helpers/actiondefs.py:162 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:141 +msgid "Copy URL" +msgstr "Copier l'URL" + +#: organizer/basic_folders/organizer/helpers/actiondefs.py:144 +msgid "Copy the shared link to clipboard" +msgstr "Copier le lien partagé dans le presse-papiers" + +#: organizer/basic_folders/organizer/helpers/actiondefs.py:154 +msgid "No shared link available for this folder" +msgstr "Aucun lien partagé disponible pour ce dossier" + +#: organizer/basic_folders/organizer/helpers/actiondefs.py:165 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:168 msgid "Copy this shared link:" msgstr "Copiez ce lien partagé :" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:170 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:176 msgid "Into Version Set" msgstr "En ensemble de versions" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:173 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:179 msgid "Turn this folder into a version set (revisions of one media file)" msgstr "" "Transformer ce dossier en ensemble de versions (révisions d'un même fichier " "multimédia)" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:179 -#: organizer/basic_folders/organizer/helpers/actiondefs.py:196 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:185 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:202 msgid "No folder selected" msgstr "Aucun dossier sélectionné" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:187 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:193 msgid "Into Normal Folder" msgstr "En dossier normal" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:190 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:196 msgid "Turn this version set back into a normal folder" msgstr "Retransformer cet ensemble de versions en dossier normal" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:204 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:210 msgid "Make Versioned" msgstr "Rendre versionné" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:207 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:213 msgid "Group the selected media files into a new version set" msgstr "" "Regrouper les fichiers multimédias sélectionnés en un nouvel ensemble de " "versions" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:213 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:219 msgid "No media files selected" msgstr "Aucun fichier multimédia sélectionné" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:221 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:227 msgid "Manage Versions" msgstr "Gérer les versions" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:224 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:230 msgid "Open the version set to manage its versions" msgstr "Ouvrir l'ensemble de versions pour gérer ses versions" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:230 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:236 msgid "No version set selected" msgstr "Aucun ensemble de versions sélectionné" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:239 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:245 msgid "Set Active Version" msgstr "Définir la version active" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:242 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:248 msgid "Make this version the active one for the set" msgstr "Faire de cette version la version active de l'ensemble" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:250 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:256 msgid "Cannot set active version (missing ids)" msgstr "Impossible de définir la version active (identifiants manquants)" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:259 -msgid "Delete user" -msgstr "Supprimer l'utilisateur" - -#: organizer/basic_folders/organizer/helpers/actiondefs.py:262 -msgid "Delete user if they have no content (only empty root folder)" -msgstr "" -"Supprimer l'utilisateur s'il n'a aucun contenu (uniquement un dossier racine " -"vide)" - -#: organizer/basic_folders/organizer/helpers/actiondefs.py:269 -msgid "No user folder selected for cleanup" -msgstr "Aucun dossier utilisateur sélectionné pour le nettoyage" - -#: organizer/basic_folders/organizer/helpers/actiondefs.py:272 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:266 msgid "" "This will delete the user if they have no media files and only an empty root " "folder.\n" @@ -756,6 +938,20 @@ msgstr "" "\n" "Êtes-vous sûr ?" +#: organizer/basic_folders/organizer/helpers/actiondefs.py:271 +msgid "Delete user" +msgstr "Supprimer l'utilisateur" + +#: organizer/basic_folders/organizer/helpers/actiondefs.py:274 +msgid "Delete user if they have no content (only empty root folder)" +msgstr "" +"Supprimer l'utilisateur s'il n'a aucun contenu (uniquement un dossier racine " +"vide)" + +#: organizer/basic_folders/organizer/helpers/actiondefs.py:281 +msgid "No user folder selected for cleanup" +msgstr "Aucun dossier utilisateur sélectionné pour le nettoyage" + #: organizer/basic_folders/organizer/helpers/folders.py:212 msgid "Cannot fetch contents of another user's folder" msgstr "Impossible de récupérer le contenu du dossier d'un autre utilisateur" diff --git a/i18n/po/zh.po b/i18n/po/zh.po index 339ea00a..166f5224 100644 --- a/i18n/po/zh.po +++ b/i18n/po/zh.po @@ -1,183 +1,203 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-09 20:48+0300\n" +"POT-Creation-Date: 2026-06-25 05:21+0800\n" "Language: zh\n" "Content-Type: text/plain; charset=UTF-8\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/user_session.rs:176 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/user_session.rs:176 msgid "Internal error in authz: {desc}" msgstr "授权内部错误:{desc}" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/user_session.rs:183 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/user_session.rs:183 msgid "Permission denied" msgstr "没有权限" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/user_session.rs:217 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/user_session.rs:217 msgid "Permission denied: {desc}" msgstr "没有权限:{desc}" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:52 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:52 msgid "No such media file." msgstr "没有此媒体文件。" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:111 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:111 msgid "You have no media yet." msgstr "你还没有任何媒体文件。" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:113 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:113 msgid "All your media files" msgstr "你的所有媒体文件" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:120 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:120 msgid "Your Media" msgstr "我的媒体" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:244 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/i18n.rs:45 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:244 +#: /home/mikesolar/Projects/clapshot/server/src/i18n.rs:45 msgid "{kind} deleted." msgstr "已删除{kind}。" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:246 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:246 msgid "{kind} deleted, but cleanup had errors." msgstr "已删除{kind},但清理过程中出现错误。" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:268 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:268 msgid "Invalid file name (must contain a letter or number)" msgstr "文件名无效(必须包含字母或数字)" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:272 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:272 msgid "Name too long (max 160)" msgstr "名称过长(最多 160 个字符)" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:278 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:278 msgid "{kind} renamed." msgstr "已重命名{kind}。" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:279 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:279 msgid "New name: '{name}'" msgstr "新名称:“{name}”" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:401 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:401 msgid "Failed to edit comment." msgstr "编辑评论失败。" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:401 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:401 msgid "No such comment. Cannot edit." msgstr "没有此评论,无法编辑。" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:420 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:425 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:435 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:420 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:425 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:435 msgid "Failed to delete comment." msgstr "删除评论失败。" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:420 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:420 msgid "You can only delete your own comments" msgstr "你只能删除自己的评论" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:425 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:425 msgid "Comment has replies. Cannot delete." msgstr "评论已有回复,无法删除。" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:435 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:435 msgid "No such comment. Cannot delete." msgstr "没有此评论,无法删除。" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:478 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:495 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:478 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:496 msgid "Failed to add subtitle." msgstr "添加字幕失败。" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:478 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:478 msgid "Subtitle file already exists: '{filename}'" msgstr "字幕文件已存在:“{filename}”" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:495 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:496 msgid "WebVTT file already exists: '{filename}'" msgstr "WebVTT 文件已存在:“{filename}”" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:652 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:654 msgid "Failed to join collab session: {error}" msgstr "加入协同会话失败:{error}" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:691 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:693 msgid "Report rejected: no active collab session." msgstr "报告被拒绝:没有活动的协同会话。" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:715 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:736 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:717 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:738 msgid "No organizer session." msgstr "没有 organizer 会话。" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:777 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:779 msgid "Missing command from client: {request}" msgstr "客户端缺少命令:{request}" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/api_server/ws_handers.rs:822 +#: /home/mikesolar/Projects/clapshot/server/src/api_server/ws_handers.rs:824 msgid "Cmd '{cmd}' failed: {error}" msgstr "命令“{cmd}”执行失败:{error}" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:59 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:59 msgid "Rename item" msgstr "重命名项目" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:60 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:60 msgid "Unknown item type in rename action. Please report this bug." msgstr "重命名操作中存在未知的项目类型。请报告此错误。" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:77 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/i18n.rs:39 -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/i18n.rs:40 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:77 +#: /home/mikesolar/Projects/clapshot/server/src/i18n.rs:39 +#: /home/mikesolar/Projects/clapshot/server/src/i18n.rs:40 msgid "Rename" msgstr "重命名" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:84 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:84 msgid "Rename selected items" msgstr "重命名所选项目" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:99 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:99 msgid "Are you sure you want to trash this item?" msgstr "确定要将此项目移至回收站吗?" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:100 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:100 msgid "Are you sure you want to trash '{title}'?" msgstr "确定要将 '{title}' 移至回收站吗?" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:101 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:101 msgid "Are you sure you want to trash folder '{title}' and ALL CONTENTS?" msgstr "确定要将文件夹 '{title}' 及其全部内容移至回收站吗?" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:102 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:102 msgid "Are you sure you want to trash ALL selected items?" msgstr "确定要将所有选定项目移至回收站吗?" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:103 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:103 msgid "Unknown item type in trash action. Please report this bug." msgstr "回收站操作中存在未知的项目类型。请报告此错误。" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:134 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:134 msgid "Trash" msgstr "移至回收站" -#: /Users/jarno/projects/clapshot-mrg/clapshot/server/src/grpc/mod.rs:141 +#: /home/mikesolar/Projects/clapshot/server/src/grpc/mod.rs:141 msgid "Trash selected items" msgstr "将所选项目移至回收站" -#: client/src/App.svelte:319 +#: client/src/App.svelte:228 +msgid "Comment not found" +msgstr "未找到评论" + +#: client/src/App.svelte:269 +msgid "Subtitle not found. See log." +msgstr "未找到字幕。请查看日志。" + +#: client/src/App.svelte:310 +msgid "Error adding subtitle file. See log." +msgstr "添加字幕文件时出错。请查看日志。" + +#: client/src/App.svelte:320 msgid "Are you sure you want to delete this subtitle?" msgstr "确定要删除此字幕吗?" -#: client/src/App.svelte:342 +#: client/src/App.svelte:343 msgid "Invalid time offset: {offset}" msgstr "时间偏移无效:{offset}" -#: client/src/App.svelte:739 +#: client/src/App.svelte:399 +msgid "Unknown URL parameter: '{key}'" +msgstr "未知 URL 参数:'{key}'" + +#: client/src/App.svelte:695 +msgid "Client error: {error}" +msgstr "客户端错误:{error}" + +#: client/src/App.svelte:738 msgid "Server version too old (v{version}). Please update server." msgstr "服务器版本过旧(v{version}),请更新服务器。" -#: client/src/App.svelte:746 +#: client/src/App.svelte:745 msgid "" "Client version too old (client v{clientVersion} for server " "v{serverVersion}). Please update client." @@ -185,74 +205,107 @@ msgstr "" "客户端版本过旧(客户端 v{clientVersion} 对应服务器 v{serverVersion}),请更新" "客户端。" -#: client/src/App.svelte:896 +#: client/src/App.svelte:753 +msgid "No user in welcome message" +msgstr "欢迎消息中没有用户" + +#: client/src/App.svelte:783 client/src/App.svelte:877 +msgid "Clapshot - {title}" +msgstr "Clapshot - {title}" + +#: client/src/App.svelte:787 +msgid "Home" +msgstr "主页" + +#: client/src/App.svelte:895 msgid "Bad video open request. See log." msgstr "视频打开请求无效,请查看日志。" -#: client/src/App.svelte:1064 +#: client/src/App.svelte:940 +msgid "{user} is controlling" +msgstr "{user} 正在控制" + +#: client/src/App.svelte:981 +msgid "No open action for item. See log." +msgstr "项目没有打开操作。请查看日志。" + +#: client/src/App.svelte:1021 +msgid "BUG: Unsupported script lang. See log." +msgstr "错误:不支持的脚本语言。请查看日志。" + +#: client/src/App.svelte:1032 +msgid "Organizer script error. See log." +msgstr "Organizer 脚本错误。请查看日志。" + +#: client/src/App.svelte:1063 msgid "Connecting server..." msgstr "正在连接服务器..." -#: client/src/App.svelte:1072 +#: client/src/App.svelte:1071 msgid "View connection errors" msgstr "查看连接错误" -#: client/src/App.svelte:1078 +#: client/src/App.svelte:1077 msgid "See browser JS console for more details." msgstr "更多详情请查看浏览器 JS 控制台。" -#: client/src/App.svelte:1110 +#: client/src/App.svelte:1109 msgid "Sort: date posted" msgstr "排序:发布日期" -#: client/src/App.svelte:1110 +#: client/src/App.svelte:1109 msgid "Sort: timecode" msgstr "排序:时间码" -#: client/src/App.svelte:1130 +#: client/src/App.svelte:1129 msgid "Subtitles" msgstr "字幕" -#: client/src/App.svelte:1152 +#: client/src/App.svelte:1130 client/src/lib/player_view/VideoPlayer.svelte:861 +#: client/src/lib/player_view/VideoPlayer.svelte:862 +msgid "Upload subtitles" +msgstr "上传字幕" + +#: client/src/App.svelte:1151 msgctxt "collab" msgid "Collaborative viewing session active." msgstr "协同观看会话已启用。" -#: client/src/App.svelte:1153 +#: client/src/App.svelte:1152 msgctxt "collab" msgid "Session ID is {id}" msgstr "会话 ID:{id}" -#: client/src/App.svelte:1154 +#: client/src/App.svelte:1153 msgctxt "collab" msgid "Actions like seek, play and draw are mirrored to all participants." msgstr "寻址、播放、绘图等操作会同步给所有参与者。" -#: client/src/App.svelte:1155 +#: client/src/App.svelte:1154 msgctxt "collab" msgid "To invite people, copy browser URL and send it to them." msgstr "邀请他人时,复制浏览器链接并发送给他们。" -#: client/src/App.svelte:1156 +#: client/src/App.svelte:1155 msgctxt "collab" msgid "Exit by clicking the green icon in header." msgstr "点击标题栏中的绿色图标退出。" #. button to acknowledge -#: client/src/App.svelte:1157 +#: client/src/App.svelte:1156 msgctxt "collab" msgid "Understood" msgstr "知道了" -#: client/src/App.svelte:1186 +#: client/src/App.svelte:1185 msgid "Drop video, audio and image files here to upload" msgstr "将视频、音频或图片文件拖到此处上传" -#: client/src/App.svelte:1217 +#: client/src/App.svelte:1216 msgid "Latest messages" msgstr "最新消息" -#: client/src/lib/NavBar.svelte:70 +#: client/src/lib/NavBar.svelte:74 msgid "" "Basic auth logout failed.\n" "Status code from {url}: {status} (not 401)" @@ -260,7 +313,7 @@ msgstr "" "Basic 认证退出失败。\n" "来自 {url} 的状态码:{status}(非 401)" -#: client/src/lib/NavBar.svelte:79 +#: client/src/lib/NavBar.svelte:83 msgid "" "Clapshot Client version {version}\n" "\n" @@ -272,7 +325,7 @@ msgstr "" "请访问项目主页:\n" "https://github.com/elonen/clapshot\n" -#: client/src/lib/NavBar.svelte:88 +#: client/src/lib/NavBar.svelte:92 msgid "" "Link copied to clipboard.\n" "Send it to reviewers who have user accounts here." @@ -280,85 +333,135 @@ msgstr "" "链接已复制到剪贴板。\n" "请将其发送给在本系统拥有用户账户的观看者。" -#: client/src/lib/NavBar.svelte:140 +#: client/src/lib/NavBar.svelte:138 +msgid "Open menu" +msgstr "打开菜单" + +#: client/src/lib/NavBar.svelte:144 msgid "Share to logged in users" msgstr "分享给已登录用户" -#: client/src/lib/NavBar.svelte:142 +#: client/src/lib/NavBar.svelte:146 msgid "Download original" msgstr "下载原文件" -#: client/src/lib/NavBar.svelte:145 +#: client/src/lib/NavBar.svelte:149 msgctxt "collab" msgid "Leave collaborative Session" msgstr "离开协同会话" -#: client/src/lib/NavBar.svelte:147 +#: client/src/lib/NavBar.svelte:151 msgctxt "collab" msgid "Collaboration is disabled here" msgstr "此处无法使用协同会话" -#: client/src/lib/NavBar.svelte:147 client/src/lib/NavBar.svelte:149 +#: client/src/lib/NavBar.svelte:151 client/src/lib/NavBar.svelte:153 msgctxt "collab" msgid "Start Collaborative Session" msgstr "开始协同会话" -#: client/src/lib/NavBar.svelte:153 +#: client/src/lib/NavBar.svelte:157 msgid "Experimental tools" msgstr "实验工具" -#: client/src/lib/NavBar.svelte:157 client/src/lib/tools/EDLImport.svelte:107 +#: client/src/lib/NavBar.svelte:161 client/src/lib/tools/EDLImport.svelte:107 msgid "Import EDL as Comments" msgstr "导入 EDL 为评论" -#: client/src/lib/NavBar.svelte:158 -#: client/src/lib/tools/comment-export/ExportDialog.svelte:72 +#: client/src/lib/NavBar.svelte:162 +#: client/src/lib/tools/comment-export/ExportDialog.svelte:69 msgid "Export Comments" msgstr "导出评论" -#: client/src/lib/NavBar.svelte:190 client/src/lib/NavBar.svelte:191 +#: client/src/lib/NavBar.svelte:177 +#: client/src/lib/asset_browser/VideoTile.svelte:76 +msgid "Processing..." +msgstr "处理中..." + +#: client/src/lib/NavBar.svelte:205 client/src/lib/NavBar.svelte:206 msgctxt "menu" msgid "Language" msgstr "语言" -#: client/src/lib/NavBar.svelte:200 client/src/lib/NavBar.svelte:217 +#: client/src/lib/NavBar.svelte:215 client/src/lib/NavBar.svelte:232 msgid "Logout" msgstr "退出" #. menu item that opens the 'About this app' dialog -#: client/src/lib/NavBar.svelte:202 +#: client/src/lib/NavBar.svelte:217 msgctxt "menu" msgid "About" msgstr "关于" -#: client/src/lib/NavBar.svelte:218 +#: client/src/lib/NavBar.svelte:233 msgid "Reload page to log in again." msgstr "请重新加载页面以再次登录。" -#: client/src/lib/asset_browser/FileUpload.svelte:51 +#: client/src/lib/UserMessage.svelte:21 +msgid "OK" +msgstr "确定" + +#: client/src/lib/UserMessage.svelte:23 +msgid "ERROR" +msgstr "错误" + +#: client/src/lib/UserMessage.svelte:25 +msgid "PROGRESS" +msgstr "进度" + +#: client/src/lib/asset_browser/FileUpload.svelte:53 msgid "{percent}% uploaded... please wait" msgstr "已上传 {percent}% ... 请稍候" -#: client/src/lib/asset_browser/FileUpload.svelte:61 +#: client/src/lib/asset_browser/FileUpload.svelte:63 msgid "Upload Failed" msgstr "上传失败" -#: client/src/lib/asset_browser/FileUpload.svelte:66 +#: client/src/lib/asset_browser/FileUpload.svelte:68 msgid "Upload Aborted" msgstr "上传已中止" -#: client/src/lib/asset_browser/FileUpload.svelte:76 +#: client/src/lib/asset_browser/FileUpload.svelte:78 msgid "Uploading: {filename}..." msgstr "正在上传:{filename}..." -#: client/src/lib/asset_browser/FileUpload.svelte:101 +#: client/src/lib/asset_browser/FileUpload.svelte:105 msgid "Drop rejected. Only video, audio and image files are allowed." msgstr "不支持此文件类型。仅支持视频、音频和图片。" +#: client/src/lib/asset_browser/FolderListing.svelte:182 +msgid "UI BUG: item not found" +msgstr "界面错误:未找到项目" + +#: client/src/lib/asset_browser/FolderListing.svelte:188 +msgid "UI BUG: item not found or missing openAction" +msgstr "界面错误:未找到项目或缺少打开操作" + +#: client/src/lib/asset_browser/FolderListing.svelte:275 +#: client/src/lib/asset_browser/FolderListing.svelte:286 +msgid "UI BUG: popup container missing" +msgstr "界面错误:弹出菜单容器缺失" + +#: client/src/lib/asset_browser/FolderListing.svelte:277 +msgid "UI BUG: popup container child missing hide()" +msgstr "界面错误:弹出菜单子项缺少 hide()" + +#: client/src/lib/asset_browser/FolderListing.svelte:423 +msgid "Unknown item type" +msgstr "未知项目类型" + #: client/src/lib/asset_browser/FolderTile.svelte:61 msgid "This target accepts media — folders can’t be dropped here." msgstr "此目标只接受媒体文件——不能将文件夹拖放到此处。" +#: client/src/lib/asset_browser/VideoTile.svelte:42 +msgid "(no date)" +msgstr "(无日期)" + +#: client/src/lib/asset_browser/VideoTile.svelte:69 +msgid "video icon" +msgstr "视频图标" + #: client/src/lib/player_view/CommentCard.svelte:44 msgid "Delete comment?" msgstr "删除评论?" @@ -409,6 +512,10 @@ msgctxt "comment" msgid "Redo" msgstr "重做" +#: client/src/lib/player_view/CommentInput.svelte:70 +msgid "Select {color} color" +msgstr "选择 {color} 颜色" + #: client/src/lib/player_view/CommentInput.svelte:80 msgid "Add a comment - at current time..." msgstr "添加评论 —— 使用当前时间点..." @@ -444,6 +551,10 @@ msgstr "标题" msgid "Language code" msgstr "语言代码" +#: client/src/lib/player_view/SubtitleCard.svelte:80 +msgid "ISO 639 language codes information" +msgstr "ISO 639 语言代码信息" + #: client/src/lib/player_view/SubtitleCard.svelte:85 msgid "Time offset (sec)" msgstr "时间偏移(秒)" @@ -481,27 +592,100 @@ msgstr "视频尚未加载完成?无法启用绘制。" msgid "Failed to show image." msgstr "显示图像失败。" +#: client/src/lib/player_view/VideoPlayer.svelte:829 +msgid "Step backwards" +msgstr "后退一帧" + +#: client/src/lib/player_view/VideoPlayer.svelte:830 +msgid "Play/Pause" +msgstr "播放/暂停" + +#: client/src/lib/player_view/VideoPlayer.svelte:831 +msgid "Step forwards" +msgstr "前进一帧" + +#. short label for the frame number input field +#: client/src/lib/player_view/VideoPlayer.svelte:836 +msgctxt "frame label" +msgid "FR" +msgstr "帧" + +#: client/src/lib/player_view/VideoPlayer.svelte:843 +msgid "Set loop start to current frame" +msgstr "将循环起点设为当前帧" + +#: client/src/lib/player_view/VideoPlayer.svelte:845 +msgid "Set loop end to current frame" +msgstr "将循环终点设为当前帧" + +#: client/src/lib/player_view/VideoPlayer.svelte:855 +#: client/src/lib/player_view/VideoPlayer.svelte:856 +msgid "Toggle closed captioning" +msgstr "切换字幕" + +#: client/src/lib/player_view/VideoPlayer.svelte:876 +msgid "Mute audio" +msgstr "静音" + +#: client/src/lib/player_view/VideoPlayer.svelte:876 +msgid "Unmute audio" +msgstr "取消静音" + +#: client/src/lib/player_view/VideoPlayer.svelte:890 +msgid "Toggle fullscreen (f)" +msgstr "切换全屏 (f)" + +#: client/src/lib/player_view/VideoPlayer.svelte:891 +msgid "Toggle fullscreen" +msgstr "切换全屏" + #: client/src/lib/tools/EDLImport.svelte:86 msgid "No time spans found when parsing EDL." msgstr "解析 EDL 时未找到任何时间区间。" +#: client/src/lib/tools/EDLImport.svelte:109 +msgid "Upload EDL" +msgstr "上传 EDL" + +#: client/src/lib/tools/EDLImport.svelte:111 +#: client/src/lib/tools/comment-export/ExportDialog.svelte:88 +msgid "Frame rate" +msgstr "帧率(FPS)" + +#: client/src/lib/tools/EDLImport.svelte:117 +msgid "Add as comments" +msgstr "添加为评论" + +#: client/src/lib/tools/EDLImport.svelte:119 +#: client/src/lib/tools/comment-export/ExportDialog.svelte:152 +msgid "Cancel" +msgstr "取消" + #: client/src/lib/tools/EDLImport.svelte:124 msgid "Parsed spans" msgstr "已解析的时间区间" -#: client/src/lib/tools/comment-export/ExportDialog.svelte:76 +#: client/src/lib/tools/comment-export/ExportDialog.svelte:73 msgid "Export format" msgstr "导出格式" -#: client/src/lib/tools/comment-export/ExportDialog.svelte:82 -msgid "Frame rate" -msgstr "帧率(FPS)" - -#: client/src/lib/tools/comment-export/ExportDialog.svelte:89 +#: client/src/lib/tools/comment-export/ExportDialog.svelte:95 msgid "Format options" msgstr "格式选项" -#: client/src/lib/tools/comment-export/ExportDialog.svelte:155 +#: client/src/lib/tools/comment-export/ExportDialog.svelte:150 +msgid "Export" +msgstr "导出" + +#: client/src/lib/tools/comment-export/ExportDialog.svelte:158 +msgid "1 comment will be exported." +msgstr "将导出 1 条评论。" + +#: client/src/lib/tools/comment-export/ExportDialog.svelte:158 +msgid "{n} comments will be exported." +msgstr "将导出 {n} 条评论。" + +#: client/src/lib/tools/comment-export/ExportDialog.svelte:161 msgid "No comments to export." msgstr "没有可导出的评论。" @@ -618,19 +802,7 @@ msgstr "未选择要取消分享的文件夹" msgid "Are you sure you want to revoke the shared link for this folder?" msgstr "确定要撤销此文件夹的分享链接吗?" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:135 -msgid "Copy URL" -msgstr "复制链接" - -#: organizer/basic_folders/organizer/helpers/actiondefs.py:138 -msgid "Copy the shared link to clipboard" -msgstr "将分享链接复制到剪贴板" - -#: organizer/basic_folders/organizer/helpers/actiondefs.py:148 -msgid "No shared link available for this folder" -msgstr "此文件夹没有可用的分享链接" - -#: organizer/basic_folders/organizer/helpers/actiondefs.py:157 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:136 msgid "" "Shared link copied to clipboard!\n" "\n" @@ -643,81 +815,81 @@ msgstr "" "注意:分享文件夹会公开其中当前所有文件的直接链接,实际上等于授予接收者对这些" "文件的永久访问权限,即使之后取消文件夹分享也是如此。" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:159 -#: organizer/basic_folders/organizer/helpers/actiondefs.py:162 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:141 +msgid "Copy URL" +msgstr "复制链接" + +#: organizer/basic_folders/organizer/helpers/actiondefs.py:144 +msgid "Copy the shared link to clipboard" +msgstr "将分享链接复制到剪贴板" + +#: organizer/basic_folders/organizer/helpers/actiondefs.py:154 +msgid "No shared link available for this folder" +msgstr "此文件夹没有可用的分享链接" + +#: organizer/basic_folders/organizer/helpers/actiondefs.py:165 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:168 msgid "Copy this shared link:" msgstr "复制此分享链接:" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:170 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:176 msgid "Into Version Set" msgstr "转为版本集" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:173 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:179 msgid "Turn this folder into a version set (revisions of one media file)" msgstr "将此文件夹转换为版本集(同一媒体文件的多个修订版本)" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:179 -#: organizer/basic_folders/organizer/helpers/actiondefs.py:196 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:185 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:202 msgid "No folder selected" msgstr "未选择文件夹" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:187 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:193 msgid "Into Normal Folder" msgstr "转为普通文件夹" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:190 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:196 msgid "Turn this version set back into a normal folder" msgstr "将此版本集转换回普通文件夹" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:204 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:210 msgid "Make Versioned" msgstr "合并为版本集" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:207 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:213 msgid "Group the selected media files into a new version set" msgstr "将所选媒体文件归入一个新的版本集" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:213 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:219 msgid "No media files selected" msgstr "未选择媒体文件" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:221 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:227 msgid "Manage Versions" msgstr "管理版本" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:224 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:230 msgid "Open the version set to manage its versions" msgstr "打开版本集以管理其版本" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:230 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:236 msgid "No version set selected" msgstr "未选择版本集" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:239 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:245 msgid "Set Active Version" msgstr "设为当前版本" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:242 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:248 msgid "Make this version the active one for the set" msgstr "将此版本设为该版本集的活动版本" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:250 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:256 msgid "Cannot set active version (missing ids)" msgstr "无法设置活动版本(缺少 ID)" -#: organizer/basic_folders/organizer/helpers/actiondefs.py:259 -msgid "Delete user" -msgstr "删除用户" - -#: organizer/basic_folders/organizer/helpers/actiondefs.py:262 -msgid "Delete user if they have no content (only empty root folder)" -msgstr "如果用户没有任何内容(仅有空的根文件夹)则删除该用户" - -#: organizer/basic_folders/organizer/helpers/actiondefs.py:269 -msgid "No user folder selected for cleanup" -msgstr "未选择用于清理的用户文件夹" - -#: organizer/basic_folders/organizer/helpers/actiondefs.py:272 +#: organizer/basic_folders/organizer/helpers/actiondefs.py:266 msgid "" "This will delete the user if they have no media files and only an empty root " "folder.\n" @@ -733,6 +905,18 @@ msgstr "" "\n" "确定要继续吗?" +#: organizer/basic_folders/organizer/helpers/actiondefs.py:271 +msgid "Delete user" +msgstr "删除用户" + +#: organizer/basic_folders/organizer/helpers/actiondefs.py:274 +msgid "Delete user if they have no content (only empty root folder)" +msgstr "如果用户没有任何内容(仅有空的根文件夹)则删除该用户" + +#: organizer/basic_folders/organizer/helpers/actiondefs.py:281 +msgid "No user folder selected for cleanup" +msgstr "未选择用于清理的用户文件夹" + #: organizer/basic_folders/organizer/helpers/folders.py:212 msgid "Cannot fetch contents of another user's folder" msgstr "无法获取其他用户文件夹的内容" diff --git a/server/.gitignore b/server/.gitignore index b24b2ce5..4deea19b 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -1,3 +1,4 @@ +.test-bin/ target DEV_DATADIR* dist_deb diff --git a/server/Cargo.lock b/server/Cargo.lock index 99c5bd3d..b06813eb 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -29,18 +29,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -50,6 +38,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -308,6 +302,388 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "aws-config" +version = "1.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a8fc176d53d6fe85017f230405e3255cedb4a02221cb55ed6d76dccbbb099b2" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 1.3.1", + "time", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e26bbf46abc608f2dc61fd6cb3b7b0665497cc259a21520151ed98f8b37d2c79" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "aws-runtime" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f92058d22a46adf53ec57a6a96f34447daf02bff52e8fb956c66bcd5c6ac12" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 0.2.12", + "http 1.3.1", + "http-body 0.4.6", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.123.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c018f22146966fdd493a664f62ee2483dff256b42a08c125ab6a084bde7b77fe" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http 0.2.12", + "http 1.3.1", + "http-body 1.0.1", + "lru", + "percent-encoding", + "regex-lite", + "sha2", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89c4f19655ab0856375e169865c91264de965bd74c407c7f1e403184b1049409" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.3.1", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f6ae9b71597dc5fd115d52849d7a5556ad9265885ad3492ea8d73b93bbc46e" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.3.1", + "percent-encoding", + "sha2", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cba48474f1d6807384d06fec085b909f5807e16653c5af5c45dfe89539f0b70" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.64.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a764fa7222922f6c0af8eea478b0ef1ba5ce1222af97e01f33ca5e957bd7f3b9" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "md-5", + "pin-project-lite", + "sha1", + "sha2", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c0b3e587fbaa5d7f7e870544508af8ce82ea47cd30376e69e1e37c4ac746f79" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4a8a5fe3e4ac7ee871237c340bbce13e982d37543b65700f4419e039f5d78e" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0709f0083aa19b704132684bc26d3c868e06bd428ccc4373b0b55c3e8748a58b" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.15", + "http 0.2.12", + "http 1.3.1", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.10.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.6", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.41", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.2", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b3a779093e18cad88bbae08dc4261e1d95018c4c5b9356a52bcae7c0b6e9bb" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3f39d5bb871aaf461d59144557f16d5927a5248a983a40654d9cf3b9ba183b" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f76a580e3d8f8961e5d48763214025a2af65c2fa4cd1fb7f270a0e107a71b0" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd3dfc18c1ce097cf81fced7192731e63809829c6cbf933c1ec47452d08e1aa" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.3.1", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c55e0837e9b8526f49e0b9bfa9ee18ddee70e853f5bc09c5d11ebceddcb0fec" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.3.1", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "576b0d6991c9c32bc14fc340582ef148311f924d41815f641a308b5d11e8e7cd" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "http 0.2.12", + "http 1.3.1", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c50f3cdf47caa8d01f2be4a6663ea02418e892f9bbfd82c7b9a3a37eaccdd3a" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.8.4" @@ -318,10 +694,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.10.1", "hyper-util", "itoa", "matchit", @@ -350,8 +726,8 @@ checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -368,6 +744,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "bitflags" version = "2.9.1" @@ -490,12 +876,25 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "cc" -version = "1.2.24" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16595d3be041c03b09d08d0858631facccee9221e579704070e6e9e4915d3bc7" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -586,6 +985,9 @@ dependencies = [ "aspasia", "assert_fs", "async-std", + "aws-config", + "aws-sdk-s3", + "aws-types", "base64", "bytes", "chrono", @@ -599,6 +1001,7 @@ dependencies = [ "futures", "futures-util", "hex", + "http 0.2.12", "http-body-util", "hyper-util", "indoc", @@ -655,6 +1058,15 @@ dependencies = [ "whoami", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.3" @@ -670,6 +1082,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -685,6 +1107,33 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc-fast" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" +dependencies = [ + "crc", + "digest", + "rustversion", + "spin", +] + [[package]] name = "crc32fast" version = "1.4.2" @@ -856,6 +1305,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -895,6 +1345,12 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.15.0" @@ -1016,6 +1472,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -1053,6 +1515,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "funty" version = "2.0.0" @@ -1236,16 +1704,35 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.10" +version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http", + "http 1.3.1", "indexmap", "slab", "tokio", @@ -1259,7 +1746,7 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash 0.7.8", + "ahash", ] [[package]] @@ -1274,6 +1761,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -1286,7 +1775,7 @@ dependencies = [ "base64", "bytes", "headers-core", - "http", + "http 1.3.1", "httpdate", "mime", "sha1", @@ -1298,7 +1787,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http", + "http 1.3.1", ] [[package]] @@ -1325,6 +1814,26 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.3.1" @@ -1336,6 +1845,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1343,7 +1863,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.3.1", ] [[package]] @@ -1354,8 +1874,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "pin-project-lite", ] @@ -1373,16 +1893,41 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.6.0" +version = "0.14.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" dependencies = [ "bytes", "futures-channel", + "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.15", + "http 1.3.1", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1392,19 +1937,35 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a01595e11bdcec50946522c32dde3fc6914743000a68b93000965f2f02406d" dependencies = [ - "http", - "hyper", + "http 1.3.1", + "hyper 1.10.1", "hyper-util", - "rustls", + "rustls 0.23.41", + "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.2", "tower-service", "webpki-roots 1.0.0", ] @@ -1415,7 +1976,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper", + "hyper 1.10.1", "hyper-util", "pin-project-lite", "tokio", @@ -1424,20 +1985,22 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.13" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c293b6b3d21eca78250dc7dbebd6b9210ec5530e038cbfe0661b5c47ab06e8" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", "futures-channel", - "futures-core", "futures-util", - "http", - "http-body", - "hyper", + "http 1.3.1", + "http-body 1.0.1", + "hyper 1.10.1", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.1", "tokio", "tower-service", "tracing", @@ -1648,6 +2211,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.82" @@ -1761,6 +2334,15 @@ dependencies = [ "value-bag", ] +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1782,6 +2364,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.4" @@ -1790,19 +2382,19 @@ checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "metrics" -version = "0.24.3" +version = "0.24.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" dependencies = [ - "ahash 0.8.12", "portable-atomic", + "rapidhash", ] [[package]] name = "metrics-util" -version = "0.20.1" +version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdfb1365fea27e6dd9dc1dbc19f570198bc86914533ad639dae939635f096be4" +checksum = "96f8722f8562635f92f8ed992f26df0532266eb03d5202607c20c0d7e9745e13" dependencies = [ "aho-corasick", "crossbeam-epoch", @@ -1815,6 +2407,7 @@ dependencies = [ "radix_trie", "rand 0.9.3", "rand_xoshiro", + "rapidhash", "sketches-ddsketch", ] @@ -1891,7 +2484,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", + "http 1.3.1", "httparse", "log", "memchr", @@ -1956,6 +2549,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1996,6 +2598,12 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "ordered-float" version = "5.2.0" @@ -2005,6 +2613,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking" version = "2.2.1" @@ -2402,7 +3016,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", + "rustls 0.23.41", "socket2 0.5.10", "thiserror 2.0.17", "tokio", @@ -2422,7 +3036,7 @@ dependencies = [ "rand 0.9.3", "ring", "rustc-hash", - "rustls", + "rustls 0.23.41", "rustls-pki-types", "slab", "thiserror 2.0.17", @@ -2555,6 +3169,15 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rapidhash" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a224897b65b7ce38bf7b0adb569e7d77e11460d0cc3577908b263d8b5a89aa" +dependencies = [ + "rustversion", +] + [[package]] name = "raw-cpuid" version = "11.6.0" @@ -2596,6 +3219,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -2622,11 +3251,11 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.10.1", + "hyper-rustls 0.27.6", "hyper-util", "ipnet", "js-sys", @@ -2637,7 +3266,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.41", "rustls-pemfile", "rustls-pki-types", "serde", @@ -2645,7 +3274,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.2", "tokio-util", "tower", "tower-service", @@ -2723,6 +3352,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.0.7" @@ -2738,18 +3376,43 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.27" +version = "0.21.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -2769,12 +3432,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2810,6 +3484,15 @@ dependencies = [ "sdd", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scheduled-thread-pool" version = "0.2.7" @@ -2831,6 +3514,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "sdd" version = "3.0.8" @@ -2843,6 +3536,29 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -2981,9 +3697,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -3057,6 +3773,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + [[package]] name = "sqlite-wasm-rs" version = "0.4.7" @@ -3352,13 +4074,23 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" dependencies = [ - "rustls", + "rustls 0.23.41", "tokio", ] @@ -3481,11 +4213,11 @@ dependencies = [ "axum", "base64", "bytes", - "h2", - "http", - "http-body", + "h2 0.4.15", + "http 1.3.1", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.10.1", "hyper-timeout", "hyper-util", "percent-encoding", @@ -3727,7 +4459,7 @@ checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.3.1", "httparse", "log", "rand 0.9.3", @@ -3744,7 +4476,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.3.1", "httparse", "log", "rand 0.9.3", @@ -3857,6 +4589,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vte" version = "0.14.1" @@ -3903,10 +4641,10 @@ dependencies = [ "bytes", "futures-util", "headers", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.10.1", "hyper-util", "log", "mime", @@ -4389,6 +5127,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yoke" version = "0.8.0" diff --git a/server/Cargo.toml b/server/Cargo.toml index fbcdd71a..7a5ef20e 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -125,6 +125,11 @@ Inflector = "0.11.4" serial_test = "3.1.1" aspasia = "0.2.0" metrics = "0.24" +aws-config = { version = "1.8", default-features = false, features = ["rustls", "rt-tokio"] } +aws-sdk-s3 = { version = "1.115", default-features = false, features = ["rustls"] } +aws-types = "1.3" +http = "0.2" +metrics-util = "0.20.4" [dev-dependencies] assert_fs = "1.0.13" diff --git a/server/debian/additional_files/clapshot-server.conf b/server/debian/additional_files/clapshot-server.conf index e16ae93a..889cff86 100644 --- a/server/debian/additional_files/clapshot-server.conf +++ b/server/debian/additional_files/clapshot-server.conf @@ -84,3 +84,41 @@ log = /var/log/clapshot.log # 'file-owner' uses filesystem ownership, 'folder-name' uses first subfolder name. # Use 'folder-name' to support (S)FTP uploads without OS-level user accounts. #ingest-username-from = file-owner + + +### S3-COMPATIBLE OBJECT STORAGE + +# Storage backend: 'local' (default) or 's3'. +# When set to 's3', media files are stored in an S3-compatible bucket (MinIO, AWS S3, etc.) +# instead of the local filesystem. The server still keeps the database, thumbnails, and +# temporary files locally under data-dir. +#storage-backend = local + +# S3 bucket name (required when storage-backend = s3). +#s3-bucket = clapshot-media + +# S3-compatible endpoint URL. Required for MinIO and other self-hosted S3-compatible +# services. Leave unset for AWS S3; the SDK will use the standard AWS endpoints. +#s3-endpoint = http://127.0.0.1:9000 + +# S3 region (e.g. us-east-1). For AWS S3 this overrides the AWS_REGION environment variable. +# For MinIO this is usually ignored by the server itself but may be required by SDK calls. +#s3-region = us-east-1 + +# Path prefix inside the bucket where media objects are stored. Defaults to 'videos'. +#s3-prefix = videos + +# Public base URL for the bucket/prefix. Deprecated: playback URLs are now server-mediated +# presigned redirects, so the bucket no longer needs to be publicly readable. +#s3-public-url = http://127.0.0.1:9000/clapshot-media + +# Lifetime of presigned S3 URLs returned to clients, in seconds. Defaults to 3600. +#s3-presigned-url-expiry = 3600 + +# Credentials are resolved automatically by the AWS SDK in this order: +# 1. Environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY +# 2. Shared credentials file: ~/.aws/credentials +# 3. AWS config file: ~/.aws/config (with profiles) +# 4. ECS container credentials +# 5. EC2 instance metadata (IAM role) +# For MinIO, set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to your MinIO credentials. diff --git a/server/src/api_server/file_upload.rs b/server/src/api_server/file_upload.rs index 537dadb6..42cc43aa 100644 --- a/server/src/api_server/file_upload.rs +++ b/server/src/api_server/file_upload.rs @@ -7,6 +7,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::video_pipeline::IncomingFile; +use crate::video_pipeline::TranscodePreference; use super::parse_auth_headers; use super::server_state::ServerState; use super::user_session::{org_authz_with_default, AuthzTopic, AuthzError}; @@ -33,7 +34,7 @@ pub async fn handle_multipart_upload( body: impl warp::Stream> + Unpin) -> Result, Infallible> { - let (user_id, user_name, is_admin, cookies, filtered_headers, remote_error) = parse_auth_headers(&hdrs, &server.default_user, &server.org_http_headers_regex); + let (user_id, user_name, is_admin, mut cookies, filtered_headers, remote_error) = parse_auth_headers(&hdrs, &server.default_user, &server.org_http_headers_regex); // If X-Remote-Error is set, return error response if let Some(error_msg) = remote_error { @@ -74,6 +75,22 @@ pub async fn handle_multipart_upload( } } + // Determine transcoding preference from header + let transcode_preference = hdrs + .get("x-clapshot-transcode") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_ascii_lowercase()) + .map(|s| match s.as_str() { + "true" | "1" | "yes" => TranscodePreference::Force, + "false" | "0" | "no" => TranscodePreference::Skip, + _ => TranscodePreference::Auto, + }) + .unwrap_or(TranscodePreference::Auto); + cookies.insert( + "transcode_preference".into(), + format!("{:?}", transcode_preference), + ); + // Parse the multipart stream let boundary = mime.get_param("boundary").map(|v| v.to_string()); let boundary = match boundary { @@ -166,7 +183,7 @@ pub async fn handle_multipart_upload( } } - if let Err(e) = upload_done.send(IncomingFile{ file_path: uploaded_file, user_id: user_id, cookies }) { + if let Err(e) = upload_done.send(IncomingFile{ file_path: uploaded_file, user_id, cookies, transcode_preference }) { tracing::error!("Failed to send upload ok signal: {:?}", e); return Ok(warp::reply::with_status("Internal error: failed to send upload ok signal".into(), warp::http::StatusCode::INTERNAL_SERVER_ERROR)); } diff --git a/server/src/api_server/mod.rs b/server/src/api_server/mod.rs index cd72e130..1bb28b0f 100644 --- a/server/src/api_server/mod.rs +++ b/server/src/api_server/mod.rs @@ -18,6 +18,7 @@ use futures_util::stream::StreamExt; use futures_util::SinkExt; use warp::ws::Message; use warp::http::HeaderMap; +use warp::http::StatusCode; use regex::Regex; use std::sync::atomic::Ordering::Relaxed; @@ -39,6 +40,7 @@ pub mod test_utils; pub mod tests; mod file_upload; use file_upload::handle_multipart_upload; +use crate::api_server::user_session::AuthzError; use crate::api_server::user_session::AuthzTopic; use crate::api_server::user_session::org_authz; use crate::client_cmd; @@ -425,6 +427,87 @@ fn parse_auth_headers(hdrs: &HeaderMap, default_user_id: &str, filter_regex: &Re (user_id, user_name, is_admin, app_cookies, filtered_headers, remote_error) } +/// Authenticated media redirect endpoint. +/// +/// For S3 storage this returns a 302 redirect to a short-lived presigned S3 URL. +/// For local storage this endpoint is not used (URLs point directly at /videos). +async fn handle_media_request( + media_id: String, + tail: warp::path::Tail, + headers: HeaderMap, + server: ServerState, +) -> Result, warp::Rejection> { + let (user_id, user_name, is_admin, _app_cookies, filtered_headers, remote_error) = + parse_auth_headers(&headers, &server.default_user, &server.org_http_headers_regex); + + if remote_error.is_some() { + return Ok(Box::new(StatusCode::UNAUTHORIZED)); + } + + let rel_path = tail.as_str(); + + let media_file = match models::MediaFile::get( + &mut server.db.conn().map_err(|_| warp::reject::not_found())?, + &media_id) { + Ok(v) => v, + Err(e) => { + tracing::debug!(media_id=%media_id, err=?e, "Media file not found for redirect request"); + return Ok(Box::new(StatusCode::NOT_FOUND)); + } + }; + + // Build a minimal Organizer session for authz checks. + // Organizer authz requires a live gRPC connection held by a websocket session, + // so the HTTP redirect endpoint can only enforce the default permission model + // (owner or admin). + let org_session = proto::org::UserSessionData { + sid: String::new(), + user: Some(proto::UserInfo { + id: user_id.clone(), + name: user_name, + }), + is_admin, + cookies: HashMap::new(), + http_headers: filtered_headers, + language: None, + }; + + let organizer: Option>> = None; + + let default_perm = media_file.user_id == user_id || is_admin; + if let Err(e) = user_session::org_authz_with_default( + &org_session, + "view media file", + false, + &server, + &organizer, + default_perm, + AuthzTopic::MediaFile(&media_file, + proto::org::authz_user_action_request::media_file_op::Op::View), + ).await { + match e { + AuthzError::Denied => { + tracing::warn!(media_id=%media_id, user=%user_id, "Media redirect denied"); + return Ok(Box::new(StatusCode::FORBIDDEN)); + } + } + } + + match server.storage.presigned_url(&media_id, rel_path).await { + Ok(url) => { + Ok(Box::new(warp::reply::with_header( + StatusCode::FOUND, + "Location", + url, + ))) + } + Err(e) => { + tracing::error!(media_id=%media_id, path=%rel_path, details=%e, "Failed to generate presigned media URL"); + Ok(Box::new(StatusCode::NOT_FOUND)) + } + } +} + /// Handle HTTP requests, read authentication headers and dispatch to WebSocket handler. async fn run_api_server_async( bind_addr: std::net::IpAddr, @@ -439,6 +522,7 @@ async fn run_api_server_async( let server_state_cln1 = server_state.clone(); let server_state_cln2 = server_state.clone(); let server_state_cln3 = server_state.clone(); + let server_state_cln4 = server_state.clone(); let url_base = server_state.url_base.clone(); @@ -498,6 +582,14 @@ async fn run_api_server_async( warp::fs::dir(server_state_cln1.media_files_dir.clone()) .with(warp::log("videos"))); + let rt_media = warp::path("api") + .and(warp::path("media")) + .and(warp::path::param::()) + .and(warp::path::tail()) + .and(warp::header::headers_cloned()) + .and(warp::any().map(move || server_state_cln4.clone())) + .and_then(handle_media_request); + let rt_api_ws = warp::path("api").and(warp::path("ws")) .and(warp::header::headers_cloned()) .and(warp::ws()) @@ -541,7 +633,7 @@ async fn run_api_server_async( }) }); - let routes = rt_health.or(rt_api_ws).or(rt_upload).or(rt_videos) + let routes = rt_health.or(rt_api_ws).or(rt_upload).or(rt_videos).or(rt_media) .with(warp::log("api_server")); @@ -552,7 +644,7 @@ async fn run_api_server_async( tracing::info!("Allowed CORS origins: {:?}", cors_origins); let cors_methods = ["GET", "POST", "HEAD", "OPTIONS"]; - let cors_headers = ["x-file-name", "x-clapshot-cookies", "content-type", "upgrade", "sec-websocket-protocol", "sec-websocket-version"]; + let cors_headers = ["x-file-name", "x-clapshot-cookies", "x-clapshot-transcode", "content-type", "upgrade", "sec-websocket-protocol", "sec-websocket-version"]; let routes = if cors_origins.contains(&"*") { tracing::warn!(concat!( diff --git a/server/src/api_server/server_state.rs b/server/src/api_server/server_state.rs index b02ad73a..95d4cbf4 100644 --- a/server/src/api_server/server_state.rs +++ b/server/src/api_server/server_state.rs @@ -1,3 +1,4 @@ +use crate::storage::StorageBackend; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -27,6 +28,8 @@ pub struct ServerState { pub db: Arc, pub media_files_dir: PathBuf, pub upload_dir: PathBuf, + pub media_base_url: String, + pub storage: StorageBackend, pub url_base: String, pub default_user: String, pub org_http_headers_regex: Regex, @@ -52,6 +55,7 @@ impl ServerState { media_files_dir: &Path, upload_dir: &Path, url_base: &str, + storage: StorageBackend, organizer_uri: Option, grpc_srv_listening_flag: Arc, default_user: String, @@ -63,6 +67,8 @@ impl ServerState { db, media_files_dir: media_files_dir.to_path_buf(), upload_dir: upload_dir.to_path_buf(), + media_base_url: storage.media_base_url().to_string(), + storage, grpc_srv_listening_flag, terminate_flag, url_base: url_base.to_string(), diff --git a/server/src/api_server/test_utils.rs b/server/src/api_server/test_utils.rs index ac719760..c2a50e5e 100644 --- a/server/src/api_server/test_utils.rs +++ b/server/src/api_server/test_utils.rs @@ -193,6 +193,7 @@ macro_rules! api_test { let ws_url = url_base.replace("http", "ws") + "/api/ws"; let media_files_dir = data_dir.join("videos"); let upload_dir = data_dir.join("upload"); + let storage = StorageBackend::local(media_files_dir.clone(), &url_base); let test_regex = validate_org_http_headers_regex("^X[-_]REMOTE[-_]").expect("Test regex failed"); let (notification_sink, notification_rx) = @@ -201,6 +202,7 @@ macro_rules! api_test { &media_files_dir.clone(), &upload_dir.clone(), &url_base.clone(), + storage, None, grpc_srv_listening_flag.clone(), "anonymous".to_string(), diff --git a/server/src/api_server/tests.rs b/server/src/api_server/tests.rs index 7fcc3a44..377b9401 100644 --- a/server/src/api_server/tests.rs +++ b/server/src/api_server/tests.rs @@ -1,5 +1,6 @@ #![allow(dead_code)] +use crate::storage::StorageBackend; use std::sync::Arc; use std::str::FromStr; use std::sync::atomic::AtomicBool; @@ -13,10 +14,9 @@ use crate::database::DbBasicQuery; use crate::database::error::DBError; use crate::api_server::{parse_auth_headers, run_api_server_async, validate_org_http_headers_regex, UserMessage, UserMessageTopic}; use crate::api_server::server_state::ServerState; +use crate::api_server::test_utils::{ApiTestState, expect_msg, expect_no_msg, write, open_media_file, connect_client_ws}; use crate::database::models::{self}; use crate::database::tests::make_test_db; - -use crate::api_server::test_utils::{ApiTestState, expect_msg, expect_no_msg, write, open_media_file, connect_client_ws}; use crate::grpc::db_models::proto_msg_type_to_event_name; use warp::http::{HeaderMap, HeaderValue}; @@ -163,10 +163,30 @@ async fn test_api_open_bad_media_file() pub async fn expect_user_msg(ws: &mut crate::api_server::test_utils::WsClient, evt_type: proto::user_message::Type ) -> proto::UserMessage { println!(" --expect_user_msg of type {:?} ....", evt_type); - let cmd = expect_client_cmd!(ws, ShowMessages); - assert_eq!(cmd.msgs.len(), 1); - assert_eq!(cmd.msgs[0].r#type, evt_type as i32); - cmd.msgs[0].clone() + // Loop to skip PROGRESS messages when waiting for other message types + loop { + let cmd = expect_client_cmd!(ws, ShowMessages); + // Filter out PROGRESS messages if we're not looking for them + let non_progress_msgs: Vec<_> = cmd.msgs.iter() + .filter(|m| { + if evt_type != proto::user_message::Type::Progress { + m.r#type != proto::user_message::Type::Progress as i32 + } else { + true + } + }) + .collect(); + + if non_progress_msgs.is_empty() { + // Only got PROGRESS messages, keep waiting + println!(" (skipping PROGRESS message, waiting for {:?})", evt_type); + continue; + } + + assert_eq!(non_progress_msgs.len(), 1, "Expected 1 message of type {:?}, got {} non-progress messages", evt_type, non_progress_msgs.len()); + assert_eq!(non_progress_msgs[0].r#type, evt_type as i32, "Expected message type {:?}, got type {}", evt_type, non_progress_msgs[0].r#type); + return non_progress_msgs[0].clone(); + } } /// Like `expect_user_msg`, but tolerant of slow background processing. diff --git a/server/src/api_server/user_session.rs b/server/src/api_server/user_session.rs index 0249daf1..20de8d45 100644 --- a/server/src/api_server/user_session.rs +++ b/server/src/api_server/user_session.rs @@ -151,7 +151,7 @@ pub async fn org_authz<'a>( AuthzTopic::MediaFile(v, op) => authz_op::Op::MediaFileOp( authz_op::MediaFileOp { op: op.into(), - media_file: Some(v.to_proto3(&server.url_base, vec![])) }), // omit subtitles for authz check + media_file: Some(v.to_proto3(&server.storage, vec![])) }), // omit subtitles for authz check AuthzTopic::Comment(c, op) => authz_op::Op::CommentOp( authz_op::CommentOp { op: op.into(), diff --git a/server/src/api_server/ws_handers.rs b/server/src/api_server/ws_handers.rs index 8d4cd4fa..3c874251 100644 --- a/server/src/api_server/ws_handers.rs +++ b/server/src/api_server/ws_handers.rs @@ -104,7 +104,7 @@ pub async fn msg_open_navigation_page(data: &OpenNavigationPage , ses: &mut User let mut media_files: Vec = Vec::new(); for m in models::MediaFile::get_by_user(&mut server.db.conn()?, &ses.user_id, DBPaging::default())? { let subs = models::Subtitle::get_by_media_file(&mut server.db.conn()?, &m.id, DBPaging::default())?; - media_files.push(m.to_proto3(&server.url_base, subs)); + media_files.push(m.to_proto3(&server.storage, subs)); } let h_txt = if media_files.is_empty() { @@ -143,7 +143,7 @@ pub async fn send_open_media_file_cmd(server: &ServerState, session_id: &str, me let conn = &mut server.db.conn()?; let v_db = models::MediaFile::get(conn, &media_file_id.into())?; let subs = models::Subtitle::get_by_media_file(conn, media_file_id, DBPaging::default())?; - let v = v_db.to_proto3(&server.url_base, subs); + let v = v_db.to_proto3(&server.storage, subs); if v.playback_url.is_none() { return Err(anyhow!("No playback file")); } @@ -485,6 +485,9 @@ pub async fn msg_add_subtitle(data: &AddSubtitle, ses: &mut UserSession, server: }; tokio::fs::write(&orig_sub_file, file_contents).await.context("Failed to write orig subtitle file")?; + let storage = server.storage.clone(); + let orig_sub_file_clone = orig_sub_file.clone(); + tokio::task::spawn_blocking(move || storage.upload_if_exists(&orig_sub_file_clone)).await.ok(); // Convert to WebVTT if needed let playback_filename ={ @@ -521,6 +524,9 @@ pub async fn msg_add_subtitle(data: &AddSubtitle, ses: &mut UserSession, server: } temp_workaround_aspasia_webvtt_bug(&vtt_path)?; + let storage = server.storage.clone(); + let vtt_path_clone = vtt_path.clone(); + tokio::task::spawn_blocking(move || storage.upload_if_exists(&vtt_path_clone)).await.ok(); Some(vtt_path.file_name().context("Bad filename")?.to_str().context("Bad filename")?.to_string()) }, Err(e) => return Err(anyhow!("Failed to parse subtitle file: {:?}", e)), diff --git a/server/src/grpc/db_models.rs b/server/src/grpc/db_models.rs index 6c1e7458..b085d863 100644 --- a/server/src/grpc/db_models.rs +++ b/server/src/grpc/db_models.rs @@ -1,6 +1,7 @@ use lib_clapshot_grpc::proto; use crate::database::{error::{DBError, DBResult}, DBPaging, DbQueryByMediaFile, PooledConnection}; use crate::database::models; +use crate::storage::StorageBackend; use super::{datetime_to_proto3, proto3_to_datetime}; @@ -53,7 +54,7 @@ impl models::MediaFile }) } - pub fn to_proto3(&self, url_base: &str, subtitles: Vec) -> proto::MediaFile + pub fn to_proto3(&self, storage: &StorageBackend, subtitles: Vec) -> proto::MediaFile { let duration = match (self.duration, self.total_frames, &self.fps) { (Some(dur), Some(total_frames), Some(fps)) => Some(proto::MediaFileDuration { @@ -75,12 +76,12 @@ impl models::MediaFile // Make preview data (thumb sheet and/or thumb url) let thumb_url = if matches!(self.has_thumbnail, Some(true)) { - Some(format!("{}/videos/{}/thumbs/thumb.webp", &url_base, &self.id)) + Some(storage.media_url(&format!("{}/thumbs/thumb.webp", &self.id))) } else { None }; let thumb_sheet = match (self.thumb_sheet_cols, self.thumb_sheet_rows) { (Some(cols), Some(rows)) => Some(proto::media_file_preview_data::ThumbSheet { - url: format!("{}/videos/{}/thumbs/sheet-{}x{}.webp", &url_base, &self.id, cols, rows), + url: storage.media_url(&format!("{}/thumbs/sheet-{}x{}.webp", &self.id, cols, rows)), rows: rows as u32, cols: cols as u32, }), @@ -110,10 +111,10 @@ impl models::MediaFile added_time: Some(datetime_to_proto3(&self.added_time)), preview_data, processing_metadata, - subtitles: subtitles.into_iter().map(|s| s.to_proto3(url_base)).collect(), + subtitles: subtitles.into_iter().map(|s| s.to_proto3(storage)).collect(), default_subtitle_id: self.default_subtitle_id.map(|id| id.to_string()), - playback_url: playback_uri.map(|uri| format!("{}/videos/{}/{}", url_base, &self.id, uri)), - orig_url: orig_uri.map(|uri| format!("{}/videos/{}/{}", url_base, &self.id, uri)) + playback_url: playback_uri.map(|uri| storage.media_url(&format!("{}/{}", &self.id, uri))), + orig_url: orig_uri.map(|uri| storage.media_url(&format!("{}/{}", &self.id, uri))) } } @@ -158,18 +159,19 @@ impl models::Subtitle media_file_id: v.media_file_id.clone(), title: v.title.clone(), language_code: v.language_code.clone(), - filename: v.playback_url.split('/').last().map(|s| s.to_string()), + filename: v.playback_url.split('/').last() + .map(|s| s.split('?').next().unwrap_or(s).to_string()), orig_filename: v.orig_filename.clone(), added_time: proto3_to_datetime(added_time).ok_or(anyhow::anyhow!("Invalid 'added_time' timestamp"))?, time_offset: v.time_offset, }) } - pub fn to_proto3(&self, url_base: &str) -> proto::Subtitle + pub fn to_proto3(&self, storage: &StorageBackend) -> proto::Subtitle { - let orig_url = format!("{}/videos/{}/subs/orig/{}", url_base, &self.media_file_id, &self.orig_filename); + let orig_url = storage.media_url(&format!("{}/subs/orig/{}", &self.media_file_id, &self.orig_filename)); let playback_url = match &self.filename { - Some(f) => format!("{}/videos/{}/subs/{}", url_base, &self.media_file_id, f), + Some(f) => storage.media_url(&format!("{}/subs/{}", &self.media_file_id, f)), None => orig_url.clone() }; proto::Subtitle { @@ -198,7 +200,8 @@ impl models::SubtitleInsert media_file_id: s.media_file_id.clone(), title: s.title.clone(), language_code: s.language_code.clone(), - filename: s.playback_url.split('/').last().map(|s| s.to_string()), + filename: s.playback_url.split('/').last() + .map(|s| s.split('?').next().unwrap_or(s).to_string()), orig_filename: s.orig_filename.clone(), time_offset: s.time_offset, }) diff --git a/server/src/grpc/grpc_server.rs b/server/src/grpc/grpc_server.rs index 73b26185..1fa08b76 100644 --- a/server/src/grpc/grpc_server.rs +++ b/server/src/grpc/grpc_server.rs @@ -131,7 +131,7 @@ impl org::organizer_outbound_server::OrganizerOutbound for OrganizerOutboundImpl }; let mut proto_items = Vec::with_capacity(items.len()); - for mf in items { proto_items.push(mf.to_proto3(&self.server.url_base, mf.get_subtitles(conn)?)); } + for mf in items { proto_items.push(mf.to_proto3(&self.server.storage, mf.get_subtitles(conn)?)); } Ok(Response::new(org::DbMediaFileList { items: proto_items, @@ -231,7 +231,7 @@ impl org::organizer_outbound_server::OrganizerOutbound for OrganizerOutboundImpl media_files: upsert_type!([ conn, req.media_files, models::MediaFile, models::MediaFileInsert, |it: &proto::MediaFile| it.id.is_empty(), - |it: &models::MediaFile| Ok(it.to_proto3(self.server.url_base.as_str(), it.get_subtitles(conn)?))])?, + |it: &models::MediaFile| Ok(it.to_proto3(&self.server.storage, it.get_subtitles(conn)?))])?, comments: upsert_type!([ conn, req.comments, models::Comment, models::CommentInsert, |it: &proto::Comment| it.id.is_empty(), @@ -243,7 +243,7 @@ impl org::organizer_outbound_server::OrganizerOutbound for OrganizerOutboundImpl subtitles: upsert_type!([ conn, req.subtitles, models::Subtitle, models::SubtitleInsert, |it: &proto::Subtitle| it.id.is_empty(), - |it: &models::Subtitle| Ok(it.to_proto3(self.server.url_base.as_str()))])?, + |it: &models::Subtitle| Ok(it.to_proto3(&self.server.storage))])?, })) } diff --git a/server/src/lib.rs b/server/src/lib.rs index c26271ed..8bded1c4 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -14,6 +14,7 @@ pub mod notification; pub mod tests; pub mod grpc; pub mod i18n; +pub mod storage; pub const PKG_VERSION: &'static str = env!("CARGO_PKG_VERSION"); pub const PKG_NAME: &'static str = env!("CARGO_PKG_NAME"); @@ -51,6 +52,7 @@ impl ClapshotInit { notification_script: Option, notification_events: String, org_http_headers_regex: regex::Regex, + storage: crate::storage::StorageBackend, terminate_flag: Arc) -> anyhow::Result { @@ -66,7 +68,7 @@ impl ClapshotInit { } // Create subdirectories - for d in &["videos", "incoming", "videos"] { + for d in &["videos", "incoming", "upload"] { std::fs::create_dir_all(&data_dir.join(d))?; } @@ -107,6 +109,7 @@ impl ClapshotInit { &data_dir.join("videos"), &data_dir.join("upload"), &url_base, + storage.clone(), organizer_uri.clone(), grpc_srv_listening_flag.clone(), default_user, @@ -149,7 +152,7 @@ impl ClapshotInit { let vpp_thread = Some({ let db = db.clone(); thread::spawn(move || { video_pipeline::run_forever( - db, tf.clone(), dd, user_msg_tx, poll_interval, resubmit_delay, target_bitrate, upload_rx, n_workers, ingest_username_from, ts, ths, tds)}) + db, tf.clone(), dd, storage.clone(), user_msg_tx, poll_interval, resubmit_delay, target_bitrate, upload_rx, n_workers, ingest_username_from, ts, ths, tds)}) }); @@ -391,6 +394,7 @@ pub fn run_clapshot( notification_script: Option, notification_events: String, org_http_headers_regex: regex::Regex, + storage: crate::storage::StorageBackend, ) -> anyhow::Result<()> { let terminate_flag = Arc::new(AtomicBool::new(false)); @@ -417,6 +421,7 @@ pub fn run_clapshot( notification_script, notification_events, org_http_headers_regex, + storage, terminate_flag.clone() )?; diff --git a/server/src/main.rs b/server/src/main.rs index aae22269..7b8eb355 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -4,9 +4,10 @@ use clapshot_server::{ api_server::validate_org_http_headers_regex, grpc::{grpc_client::prepare_organizer, grpc_server::make_grpc_server_bind}, run_clapshot, PKG_NAME, PKG_VERSION, + storage::StorageBackend, video_pipeline::IngestUsernameFrom, }; -use std::{path::PathBuf, sync::Arc, str::FromStr}; +use std::{path::PathBuf, str::FromStr, sync::Arc}; use tracing::error; use indoc::indoc; @@ -159,6 +160,39 @@ struct Args { /// e.g. '^X[-_]REMOTE[-_]USER[-_]CAN[-_]UPLOAD$' rather than '^X[-_]REMOTE[-_]'. #[arg(long, value_name="REGEX", default_value="^$")] org_http_headers: String, + + /// Storage backend (local or s3-compatible object storage) + #[arg(long, value_name="BACKEND", default_value="local")] + storage_backend: String, + + /// S3-compatible endpoint URL (only needed for non-AWS S3, e.g. MinIO). + /// For AWS S3, leave unset and configure AWS_REGION instead. + #[arg(long, value_name="URL")] + s3_endpoint: Option, + + /// S3 region (e.g. us-east-1). For AWS S3 this overrides AWS_REGION. + /// For MinIO this is usually ignored but may be required by some SDK calls. + #[arg(long, value_name="REGION")] + s3_region: Option, + + /// S3 bucket (required for S3 backend) + #[arg(long, value_name="BUCKET")] + s3_bucket: Option, + + /// Path/prefix inside the bucket where media files are stored + #[arg(long, value_name="PREFIX", default_value="videos")] + s3_prefix: String, + + /// Public base URL for accessing the bucket/prefix (used for playback URLs). + /// If not set, defaults to endpoint/bucket or virtual-hosted style URL. + /// Deprecated: playback URLs are now server-mediated presigned redirects, + /// so the bucket no longer needs to be publicly readable. + #[arg(long, value_name="URL")] + s3_public_url: Option, + + /// Presigned S3 URL expiry time in seconds (default: 3600). + #[arg(long, value_name="SECONDS", default_value="3600")] + s3_presigned_url_expiry: u64, } fn main() -> anyhow::Result<()> { @@ -219,6 +253,30 @@ fn main() -> anyhow::Result<()> { // Validate and compile the org_http_headers regex let org_http_headers_regex = validate_org_http_headers_regex(&args.org_http_headers)?; + let storage = match args.storage_backend.as_str() { + "local" => StorageBackend::local(args.data_dir.join("videos"), &url_base), + "s3" => { + let bucket = args.s3_bucket.clone() + .ok_or_else(|| anyhow::anyhow!("--s3-bucket is required for S3 backend"))?; + + if args.s3_public_url.is_some() { + tracing::info!("--s3-public-url is deprecated; playback now uses server-mediated presigned S3 URLs"); + } + + StorageBackend::s3( + args.data_dir.join("videos"), + bucket, + args.s3_endpoint.clone(), + args.s3_region.clone(), + args.s3_prefix.clone(), + args.s3_public_url.clone(), + url_base.clone(), + std::time::Duration::from_secs(args.s3_presigned_url_expiry), + )? + } + other => bail!("Unknown storage backend '{}'. Valid options: local, s3", other), + }; + // Run the server (blocking) if let Err(e) = run_clapshot( args.data_dir.to_path_buf(), @@ -241,6 +299,7 @@ fn main() -> anyhow::Result<()> { args.notification_script, args.notification_events, org_http_headers_regex, + storage, ) { error!("run_clapshot() failed: {}", e); } diff --git a/server/src/storage.rs b/server/src/storage.rs new file mode 100644 index 00000000..93ef8082 --- /dev/null +++ b/server/src/storage.rs @@ -0,0 +1,783 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, Context}; +use aws_sdk_s3::presigning::PresigningConfig; +use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart}; +use aws_sdk_s3::{primitives::ByteStream, Client}; +use tokio::fs; +use tokio::io::AsyncReadExt; +use tokio::runtime::Handle; +use tracing; + +pub type ProgressCallback = Arc; + +const MULTIPART_MIN_SIZE: u64 = 5 * 1024 * 1024; +const MULTIPART_CHUNK_SIZE: usize = 8 * 1024 * 1024; +const S3_RETRY_COUNT: usize = 3; +const S3_RETRY_BASE_DELAY_MS: u64 = 250; + +/// Build an S3 client with optional custom endpoint and region. +async fn build_s3_client(endpoint: &Option, s3_region: Option<&str>) -> Client { + let mut config_loader = aws_config::defaults(aws_config::BehaviorVersion::latest()); + + // Only override endpoint for non-AWS S3 (MinIO, etc.) + if let Some(ref ep) = endpoint { + config_loader = config_loader.endpoint_url(ep); + } + if let Some(region) = s3_region { + config_loader = config_loader.region(aws_sdk_s3::config::Region::new(region.to_string())); + } + + let sdk_config = config_loader.load().await; + let s3_config = aws_sdk_s3::config::Builder::from(&sdk_config) + // Force path-style for MinIO compatibility + .force_path_style(endpoint.is_some()) + .build(); + Client::from_conf(s3_config) +} + +/// Simple retry helper for async S3 operations with exponential backoff. +async fn retry_s3_operation( + desc: &str, + operation: F, +) -> anyhow::Result +where + F: Fn() -> Fut, + Fut: std::future::Future>>, + E: std::fmt::Debug + std::fmt::Display + std::error::Error + Send + Sync + 'static, +{ + let mut last_err = None; + for attempt in 0..S3_RETRY_COUNT { + match operation().await { + Ok(v) => return Ok(v), + Err(e) => { + tracing::warn!(attempt = attempt + 1, max = S3_RETRY_COUNT, err = %e, "S3 operation failed: {}", desc); + last_err = Some(e); + if attempt + 1 < S3_RETRY_COUNT { + let delay = S3_RETRY_BASE_DELAY_MS * 2u64.pow(attempt as u32); + tokio::time::sleep(Duration::from_millis(delay.min(5000))).await; + } + } + } + } + Err(anyhow!("{} failed after {} attempts: {}", desc, S3_RETRY_COUNT, last_err.unwrap())) +} + +/// Simple content type guessing for a handful of formats we serve. +pub(crate) fn guess_content_type(path: &Path) -> &'static str { + match path + .extension() + .and_then(|e| e.to_str()) + .map(|s| s.to_ascii_lowercase()) + { + Some(ext) if ext == "mp4" => "video/mp4", + Some(ext) if ext == "mkv" => "video/x-matroska", + Some(ext) if ext == "webm" => "video/webm", + Some(ext) if ext == "mov" => "video/quicktime", + Some(ext) if ext == "webp" => "image/webp", + Some(ext) if ext == "png" => "image/png", + Some(ext) if ext == "jpg" || ext == "jpeg" => "image/jpeg", + Some(ext) if ext == "vtt" => "text/vtt", + Some(ext) if ext == "srt" => "application/x-subrip", + _ => "application/octet-stream", + } +} + +#[derive(Clone)] +pub enum StorageBackend { + LocalFs(LocalFsBackend), + S3(ObjectStorageBackend), +} + +impl StorageBackend { + pub fn local(media_root: PathBuf, url_base: &str) -> Self { + let prefix = "videos".to_string(); + let media_base_url = format!("{}/{}", url_base.trim_end_matches('/'), prefix); + StorageBackend::LocalFs(LocalFsBackend { + media_root, + prefix, + url_base: url_base.to_string(), + media_base_url, + }) + } + + /// Create an S3 storage backend using the AWS SDK default credential chain. + /// + /// Credentials are resolved automatically in this order: + /// 1. Environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY + /// 2. Shared credentials file: ~/.aws/credentials + /// 3. AWS config file: ~/.aws/config (with profiles) + /// 4. ECS container credentials + /// 5. EC2 instance metadata (IAM role) + /// + /// For MinIO or other S3-compatible storage, set endpoint to the service URL. + /// For AWS S3, leave endpoint as None and configure AWS_REGION, or pass s3_region. + pub fn s3( + media_root: PathBuf, + bucket: String, + endpoint: Option, + s3_region: Option, + prefix: String, + public_base_url: Option, + url_base: String, + presigned_url_expiry: Duration, + ) -> anyhow::Result { + let public_base_url = public_base_url.unwrap_or_else(|| { + endpoint.as_ref().map(|ep| format!("{}/{}", ep.trim_end_matches('/'), &bucket)) + .unwrap_or_else(|| format!("https://{}.s3.amazonaws.com", &bucket)) + }); + let media_base_url = format!( + "{}/{}", + public_base_url.trim_end_matches('/'), + prefix.trim_end_matches('/') + ); + + // Build the SDK client. This function is called from a synchronous main(), so we need + // a temporary runtime unless we're already inside one (tests may call us from a runtime). + let client = match Handle::try_current() { + Ok(handle) => { + handle.block_on(async { + build_s3_client(&endpoint, s3_region.as_deref()).await + }) + } + Err(_) => { + let rt = tokio::runtime::Runtime::new() + .context("create tokio runtime for S3 client init")?; + rt.block_on(async { + build_s3_client(&endpoint, s3_region.as_deref()).await + }) + } + }; + + Ok(StorageBackend::S3(ObjectStorageBackend { + media_root, + prefix, + url_base, + media_base_url, + client: Arc::new(client), + bucket, + presigned_url_expiry, + })) + } + + pub fn media_base_url(&self) -> &str { + match self { + StorageBackend::LocalFs(b) => &b.media_base_url, + StorageBackend::S3(b) => &b.media_base_url, + } + } + + /// Return the client-visible URL for a media path. + /// + /// For the local backend this is a direct `/videos/...` URL. + /// For S3 this is a `/api/media/...` URL that the server will redirect to a + /// short-lived presigned S3 URL. + pub fn media_url(&self, rel_path: &str) -> String { + let rel_path = rel_path.trim_start_matches('/'); + match self { + StorageBackend::LocalFs(b) => format!("{}/{}", b.media_base_url.trim_end_matches('/'), rel_path), + StorageBackend::S3(b) => format!("{}/api/media/{}", b.url_base.trim_end_matches('/'), rel_path), + } + } + + /// Generate a short-lived presigned S3 URL for a media path. + /// + /// Returns an error for the local backend. + pub async fn presigned_url(&self, media_id: &str, rel_path: &str) -> anyhow::Result { + match self { + StorageBackend::LocalFs(_) => Err(anyhow!("presigned URLs are not available for local filesystem backend")), + StorageBackend::S3(backend) => backend.presigned_url(media_id, rel_path).await, + } + } + + pub fn media_root(&self) -> &Path { + match self { + StorageBackend::LocalFs(b) => &b.media_root, + StorageBackend::S3(b) => &b.media_root, + } + } + + pub fn needs_remote_upload(&self) -> bool { + matches!(self, StorageBackend::S3(_)) + } + + /// Upload a file that lives under the media root. No-op for LocalFS. + pub fn upload_local_path(&self, abs_path: &Path) -> anyhow::Result<()> { + self.upload_with_progress(abs_path, None) + } + + /// Upload file if it exists and log an error instead of bailing. + pub fn upload_if_exists(&self, abs_path: &Path) { + if !self.needs_remote_upload() { + return; + } + if !abs_path.exists() { + tracing::debug!(path=?abs_path, "Skipping upload for missing file"); + return; + } + if let Err(e) = self.upload_local_path(abs_path) { + tracing::error!(path=?abs_path, details=%e, "Failed to upload asset to object storage"); + } + } + + /// Upload a file when object storage is enabled, and propagate failures. + pub fn upload_required(&self, abs_path: &Path) -> anyhow::Result<()> { + if !self.needs_remote_upload() { + return Ok(()); + } + self.upload_with_progress(abs_path, None) + } + + /// Upload a file, optionally reporting progress (0.0 - 1.0) while streaming to object storage. + /// + /// This is a synchronous wrapper that can be called from any thread. If a Tokio + /// runtime handle is available it is reused; otherwise a temporary runtime is created. + pub fn upload_with_progress( + &self, + abs_path: &Path, + progress: Option, + ) -> anyhow::Result<()> { + match self { + StorageBackend::LocalFs(_) => { + if let Some(cb) = progress { + cb(1.0); + } + Ok(()) + } + StorageBackend::S3(backend) => backend.upload_with_progress(abs_path, progress), + } + } + + /// Async variant of [`Self::upload_with_progress`]. + /// + /// Callers running inside a Tokio runtime should prefer this and wrap it in + /// [`tokio::task::spawn_blocking`] if they need a synchronous interface. + pub async fn upload_with_progress_async( + &self, + abs_path: &Path, + progress: Option, + ) -> anyhow::Result<()> { + match self { + StorageBackend::LocalFs(_) => { + if let Some(cb) = progress { + cb(1.0); + } + Ok(()) + } + StorageBackend::S3(backend) => backend.upload_with_progress_async(abs_path, progress).await, + } + } + + pub(crate) fn key_for_path(&self, abs_path: &Path) -> anyhow::Result { + let root = self.media_root(); + let rel = abs_path + .strip_prefix(root) + .with_context(|| format!("Path '{:?}' not under media root '{:?}'", abs_path, root))?; + let rel = rel.to_string_lossy().replace('\\', "/"); + let prefix = match self { + StorageBackend::LocalFs(b) => &b.prefix, + StorageBackend::S3(b) => &b.prefix, + } + .trim_end_matches('/'); + + if prefix.is_empty() { + Ok(rel) + } else { + Ok(format!("{}/{}", prefix, rel)) + } + } +} + +#[derive(Clone)] +pub struct LocalFsBackend { + pub media_root: PathBuf, + pub prefix: String, + pub url_base: String, + pub media_base_url: String, +} + +#[derive(Clone)] +pub struct ObjectStorageBackend { + pub media_root: PathBuf, + pub prefix: String, + pub url_base: String, + pub media_base_url: String, + pub bucket: String, + pub client: Arc, + pub presigned_url_expiry: Duration, +} + +impl ObjectStorageBackend { + /// Generate a short-lived presigned S3 URL for `media_id/rel_path`. + async fn presigned_url(&self, + media_id: &str, + rel_path: &str, + ) -> anyhow::Result { + let rel_path = rel_path.trim_start_matches('/'); + let prefix = self.prefix.trim_end_matches('/'); + let key = if prefix.is_empty() { + format!("{}/{}", media_id, rel_path) + } else { + format!("{}/{}/{}", prefix, media_id, rel_path) + }; + + let presign_config = PresigningConfig::expires_in(self.presigned_url_expiry) + .context("invalid presigned URL expiry")?; + + let presigned = self + .client + .get_object() + .bucket(&self.bucket) + .key(&key) + .presigned(presign_config) + .await + .context("failed to presign S3 URL")?; + + Ok(presigned.uri().to_string()) + } + + fn upload_with_progress( + &self, + abs_path: &Path, + progress: Option, + ) -> anyhow::Result<()> { + let fut = self.upload_with_progress_async(abs_path, progress); + match Handle::try_current() { + Ok(handle) => handle.block_on(fut), + Err(_) => { + let rt = tokio::runtime::Runtime::new() + .context("create tokio runtime for S3 upload")?; + rt.block_on(fut) + } + } + } + + async fn upload_with_progress_async( + &self, + abs_path: &Path, + progress: Option, + ) -> anyhow::Result<()> { + let key = StorageBackend::S3(self.clone()).key_for_path(abs_path)?; + let ct = guess_content_type(abs_path); + let bucket = self.bucket.clone(); + let client = self.client.clone(); + let path = abs_path.to_path_buf(); + + let mut file = fs::File::open(&path) + .await + .with_context(|| format!("Open file {:?}", path))?; + let meta = file.metadata().await?; + let total_len = meta.len(); + + if total_len == 0 { + if let Some(cb) = progress.as_ref() { + cb(1.0); + } + retry_s3_operation("put_object (empty)", || { + let bucket = bucket.clone(); + let key = key.clone(); + let client = client.clone(); + async move { + client + .put_object() + .bucket(&bucket) + .key(&key) + .body(ByteStream::from(Vec::new())) + .content_type(ct) + .send() + .await + } + }) + .await + .context("upload empty object to storage")?; + return Ok(()); + } + + if total_len <= MULTIPART_MIN_SIZE { + let mut buffer = Vec::with_capacity(total_len as usize); + file.read_to_end(&mut buffer).await?; + + retry_s3_operation("put_object (small)", || { + let bucket = bucket.clone(); + let key = key.clone(); + let client = client.clone(); + let body = ByteStream::from(buffer.clone()); + async move { + client + .put_object() + .bucket(&bucket) + .key(&key) + .body(body) + .content_type(ct) + .send() + .await + } + }) + .await + .context("upload small object to storage")?; + + if let Some(cb) = progress { + cb(1.0); + } + return Ok(()); + } + + let upload = retry_s3_operation("create_multipart_upload", || { + let bucket = bucket.clone(); + let key = key.clone(); + let client = client.clone(); + async move { + client + .create_multipart_upload() + .bucket(&bucket) + .key(&key) + .content_type(ct) + .send() + .await + } + }) + .await + .context("initiate multipart upload")?; + + let upload_id = upload + .upload_id() + .ok_or(anyhow!("Missing upload id from multipart upload"))? + .to_string(); + + let mut parts = Vec::new(); + let mut buf = vec![0u8; MULTIPART_CHUNK_SIZE]; + let mut part_number = 1; + let mut uploaded: u64 = 0; + + loop { + // Read a complete chunk (read() may return short reads with async I/O) + // S3 requires all parts except the last to be >= 5MB + let mut chunk_size = 0; + loop { + let bytes_read = file.read(&mut buf[chunk_size..]).await?; + if bytes_read == 0 { + break; // EOF + } + chunk_size += bytes_read; + if chunk_size >= MULTIPART_CHUNK_SIZE { + break; // Full chunk + } + } + + if chunk_size == 0 { + break; // No more data + } + + let pn = part_number; + let res = retry_s3_operation(&format!("upload_part {part_number}"), + || { + let bucket = bucket.clone(); + let key = key.clone(); + let upload_id = upload_id.clone(); + let client = client.clone(); + let body = ByteStream::from(buf[..chunk_size].to_vec()); + async move { + client + .upload_part() + .bucket(&bucket) + .key(&key) + .upload_id(&upload_id) + .part_number(pn) + .body(body) + .send() + .await + } + }, + ) + .await + .with_context(|| format!("upload part {part_number}"))?; + + let etag = res + .e_tag() + .ok_or(anyhow!("Missing etag for uploaded part {part_number}"))? + .to_string(); + + parts.push( + CompletedPart::builder() + .e_tag(etag) + .part_number(part_number) + .build(), + ); + + uploaded += chunk_size as u64; + if let Some(cb) = progress.as_ref() { + cb((uploaded as f32 / total_len as f32).clamp(0.0, 1.0)); + } + + part_number += 1; + } + + let multipart = CompletedMultipartUpload::builder() + .set_parts(Some(parts)) + .build(); + + if let Err(e) = retry_s3_operation("complete_multipart_upload", || { + let bucket = bucket.clone(); + let key = key.clone(); + let upload_id = upload_id.clone(); + let client = client.clone(); + let multipart = multipart.clone(); + async move { + client + .complete_multipart_upload() + .bucket(&bucket) + .key(&key) + .upload_id(&upload_id) + .multipart_upload(multipart) + .send() + .await + } + }) + .await + { + tracing::error!( + details=%e, + upload_id=%upload_id, + key=%key, + "Completing multipart upload failed, aborting" + ); + // Best-effort abort; ignore abort error to bubble the original failure. + let _ = client + .abort_multipart_upload() + .bucket(&bucket) + .key(&key) + .upload_id(&upload_id) + .send() + .await; + return Err(anyhow!("complete multipart upload: {e}")); + } + + if let Some(cb) = progress { + cb(1.0); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; + + #[test] + fn test_guess_content_type() { + assert_eq!(guess_content_type(Path::new("foo.mp4")), "video/mp4"); + assert_eq!(guess_content_type(Path::new("foo.MP4")), "video/mp4"); + assert_eq!(guess_content_type(Path::new("foo.mkv")), "video/x-matroska"); + assert_eq!(guess_content_type(Path::new("foo.webm")), "video/webm"); + assert_eq!(guess_content_type(Path::new("foo.mov")), "video/quicktime"); + assert_eq!(guess_content_type(Path::new("foo.webp")), "image/webp"); + assert_eq!(guess_content_type(Path::new("foo.png")), "image/png"); + assert_eq!(guess_content_type(Path::new("foo.jpg")), "image/jpeg"); + assert_eq!(guess_content_type(Path::new("foo.jpeg")), "image/jpeg"); + assert_eq!(guess_content_type(Path::new("foo.vtt")), "text/vtt"); + assert_eq!(guess_content_type(Path::new("foo.srt")), "application/x-subrip"); + assert_eq!(guess_content_type(Path::new("foo.unknown")), "application/octet-stream"); + assert_eq!(guess_content_type(Path::new("foo")), "application/octet-stream"); + } + + #[test] + fn test_local_backend_accessors() { + let storage = StorageBackend::local(PathBuf::from("/data/videos"), "http://localhost:8080"); + assert_eq!(storage.media_base_url(), "http://localhost:8080/videos"); + assert_eq!(storage.media_root(), Path::new("/data/videos")); + assert!(!storage.needs_remote_upload()); + } + + #[test] + fn test_local_media_url() { + let storage = StorageBackend::local(PathBuf::from("/data/videos"), "http://localhost:8080/"); + assert_eq!( + storage.media_url("abc123/video.mp4"), + "http://localhost:8080/videos/abc123/video.mp4" + ); + assert_eq!( + storage.media_url("/abc123/thumbs/thumb.webp"), + "http://localhost:8080/videos/abc123/thumbs/thumb.webp" + ); + } + + #[test] + fn test_key_for_path_local() { + let storage = StorageBackend::local(PathBuf::from("/data/videos"), "http://localhost:8080"); + assert_eq!( + storage.key_for_path(Path::new("/data/videos/abc123/video.mp4")).unwrap(), + "videos/abc123/video.mp4" + ); + assert!(storage.key_for_path(Path::new("/outside/video.mp4")).is_err()); + } + + #[test] + fn test_key_for_path_normalizes_backslashes() { + // Even on Unix this documents the intended cross-platform behaviour. + let storage = StorageBackend::local(PathBuf::from("/data/videos"), "http://localhost:8080"); + let key = storage.key_for_path(Path::new("/data/videos/abc123\\video.mp4")).unwrap(); + assert_eq!(key, "videos/abc123/video.mp4"); + } + + #[test] + fn test_presigned_url_local_errors() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let storage = StorageBackend::local(PathBuf::from("/data/videos"), "http://localhost:8080"); + let res = rt.block_on(storage.presigned_url("abc123", "video.mp4")); + assert!(res.is_err()); + assert!(res.unwrap_err().to_string().contains("presigned URLs are not available")); + } + + #[test] + fn test_upload_with_progress_local_invokes_callback() { + let storage = StorageBackend::local(PathBuf::from("/data/videos"), "http://localhost:8080"); + let called = Arc::new(AtomicBool::new(false)); + let called2 = called.clone(); + let cb: ProgressCallback = Arc::new(move |p| { + assert!((p - 1.0).abs() < f32::EPSILON); + called2.store(true, Ordering::SeqCst); + }); + storage.upload_with_progress(Path::new("/data/videos/foo.mp4"), Some(cb)).unwrap(); + assert!(called.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn test_upload_with_progress_async_local_invokes_callback() { + let storage = StorageBackend::local(PathBuf::from("/data/videos"), "http://localhost:8080"); + let called = Arc::new(AtomicBool::new(false)); + let called2 = called.clone(); + let cb: ProgressCallback = Arc::new(move |p| { + assert!((p - 1.0).abs() < f32::EPSILON); + called2.store(true, Ordering::SeqCst); + }); + storage.upload_with_progress_async(Path::new("/data/videos/foo.mp4"), Some(cb)).await.unwrap(); + assert!(called.load(Ordering::SeqCst)); + } + + #[test] + fn test_upload_with_progress_local_from_blocking_thread() { + // The sync wrapper should reuse the current runtime handle from a blocking + // thread (spawn_blocking), which is how async callers should invoke it. + let rt = tokio::runtime::Runtime::new().unwrap(); + let storage = StorageBackend::local(PathBuf::from("/data/videos"), "http://localhost:8080"); + let called = Arc::new(AtomicBool::new(false)); + let called2 = called.clone(); + let cb: ProgressCallback = Arc::new(move |p| { + assert!((p - 1.0).abs() < f32::EPSILON); + called2.store(true, Ordering::SeqCst); + }); + rt.block_on(async { + tokio::task::spawn_blocking(move || { + storage.upload_with_progress(Path::new("/data/videos/foo.mp4"), Some(cb)).unwrap(); + }) + .await + .unwrap(); + }); + assert!(called.load(Ordering::SeqCst)); + } + + #[test] + fn test_upload_if_exists_local_noop_for_missing_path() { + let storage = StorageBackend::local(PathBuf::from("/data/videos"), "http://localhost:8080"); + storage.upload_if_exists(Path::new("/data/videos/does-not-exist.mp4")); + } + + #[test] + fn test_upload_required_local_noop() { + let storage = StorageBackend::local(PathBuf::from("/data/videos"), "http://localhost:8080"); + storage.upload_required(Path::new("/data/videos/foo.mp4")).unwrap(); + } + + #[test] + fn test_s3_default_public_base_url_for_aws() { + let storage = StorageBackend::s3( + PathBuf::from("/data/videos"), + "my-bucket".to_string(), + None, + Some("eu-west-1".to_string()), + "media".to_string(), + None, + "http://localhost:8080".to_string(), + Duration::from_secs(3600), + ) + .expect("failed to create AWS S3 backend"); + assert_eq!(storage.media_base_url(), "https://my-bucket.s3.amazonaws.com/media"); + } + + #[test] + fn test_s3_default_public_base_url_for_minio() { + let storage = StorageBackend::s3( + PathBuf::from("/data/videos"), + "my-bucket".to_string(), + Some("http://minio.example.com:9000".to_string()), + None, + "media".to_string(), + None, + "http://localhost:8080".to_string(), + Duration::from_secs(3600), + ) + .expect("failed to create MinIO backend"); + assert_eq!(storage.media_base_url(), "http://minio.example.com:9000/my-bucket/media"); + } + + #[test] + fn test_s3_media_url_and_needs_remote_upload() { + let storage = StorageBackend::s3( + PathBuf::from("/data/videos"), + "my-bucket".to_string(), + Some("http://minio.example.com".to_string()), + None, + "videos".to_string(), + None, + "http://localhost:8080".to_string(), + Duration::from_secs(3600), + ) + .expect("failed to create S3 backend"); + assert!(storage.needs_remote_upload()); + assert_eq!( + storage.media_url("abc123/video.mp4"), + "http://localhost:8080/api/media/abc123/video.mp4" + ); + } + + #[test] + fn test_s3_key_for_path_with_prefix() { + let storage = StorageBackend::s3( + PathBuf::from("/data/videos"), + "my-bucket".to_string(), + Some("http://minio.example.com".to_string()), + None, + "uploads".to_string(), + None, + "http://localhost:8080".to_string(), + Duration::from_secs(3600), + ) + .expect("failed to create S3 backend"); + assert_eq!( + storage.key_for_path(Path::new("/data/videos/abc123/video.mp4")).unwrap(), + "uploads/abc123/video.mp4" + ); + } + + #[test] + fn test_s3_key_for_path_without_prefix() { + let storage = StorageBackend::s3( + PathBuf::from("/data/videos"), + "my-bucket".to_string(), + Some("http://minio.example.com".to_string()), + None, + "".to_string(), + None, + "http://localhost:8080".to_string(), + Duration::from_secs(3600), + ) + .expect("failed to create S3 backend"); + assert_eq!( + storage.key_for_path(Path::new("/data/videos/abc123/video.mp4")).unwrap(), + "abc123/video.mp4" + ); + } +} diff --git a/server/src/tests/integration_test.rs b/server/src/tests/integration_test.rs index d585f42f..ce562d53 100644 --- a/server/src/tests/integration_test.rs +++ b/server/src/tests/integration_test.rs @@ -9,7 +9,7 @@ mod integration_test use std::sync::atomic::AtomicBool; use std::sync::{Mutex, Arc}; use std::{error, any}; - use std::{path::PathBuf, str::FromStr}; + use std::{path::{Path, PathBuf}, str::FromStr}; use std::{thread, time::Duration}; use assert_fs::prelude::PathCopy; @@ -23,7 +23,9 @@ mod integration_test use crossbeam_channel::{Receiver, RecvTimeoutError, unbounded, select}; use crate::api_server::tests::wait_for_user_msg; + use crate::api_server::tests::expect_user_msg; use crate::api_server::validate_org_http_headers_regex; + use crate::storage::StorageBackend; use crate::database::schema::media_files::{thumb_sheet_cols, thumb_sheet_rows}; use crate::{expect_client_cmd, send_server_cmd}; @@ -33,6 +35,7 @@ mod integration_test use lib_clapshot_grpc::{GrpcBindAddr, proto}; use lib_clapshot_grpc::proto::client::ServerToClientCmd; use lib_clapshot_grpc::proto::client::server_to_client_cmd as s2c; + use pbjson_types; use tracing; use tracing::{error, info, warn, instrument}; @@ -65,6 +68,7 @@ mod integration_test file_path: PathBuf::from_str(data_dir.join("NASA_Red_Lettuce_excerpt.mov").to_str().unwrap())?, user_id: "nobody".to_string(), cookies: HashMap::new(), + transcode_preference: crate::video_pipeline::TranscodePreference::Auto, }; arg_sender.send(args.clone())?; @@ -105,44 +109,55 @@ mod integration_test } macro_rules! cs_main_test { - ([$ws:ident, $data_dir:ident, $incoming_dir:ident, $org_conn:ident, $bitrate:expr, $org_cmd:expr, $custom_assertfs:expr, $ingest_username_from:expr] $($body:tt)*) => { - cs_main_test!([$ws, $data_dir, $incoming_dir, $org_conn, $bitrate, $org_cmd, $custom_assertfs, $ingest_username_from, None] $($body)*) + // 8-param variant: default storage, no ws_user_override + ([$ws:ident, $data_dir:ident, $incoming_dir:ident, $org_conn:ident, $url_base:ident, $bitrate:expr, $org_cmd:expr, $custom_assertfs:expr, $ingest_username_from:expr] $($body:tt)*) => { + cs_main_test!(@impl [$ws, $data_dir, $incoming_dir, $org_conn, $url_base, $bitrate, $org_cmd, $custom_assertfs, $ingest_username_from, None, + |media_root: std::path::PathBuf, url_base: &str| crate::storage::StorageBackend::local(media_root, url_base)] $($body)*) }; - ([$ws:ident, $data_dir:ident, $incoming_dir:ident, $org_conn:ident, $bitrate:expr, $org_cmd:expr, $custom_assertfs:expr, $ingest_username_from:expr, $ws_user_override:expr] $($body:tt)*) => { + // 9-param variant: default storage, with ws_user_override + ([$ws:ident, $data_dir:ident, $incoming_dir:ident, $org_conn:ident, $url_base:ident, $bitrate:expr, $org_cmd:expr, $custom_assertfs:expr, $ingest_username_from:expr, $ws_user_override:expr] $($body:tt)*) => { + cs_main_test!(@impl [$ws, $data_dir, $incoming_dir, $org_conn, $url_base, $bitrate, $org_cmd, $custom_assertfs, $ingest_username_from, $ws_user_override, + |media_root: std::path::PathBuf, url_base: &str| crate::storage::StorageBackend::local(media_root, url_base)] $($body)*) + }; + // 10-param variant: custom storage factory (receives media_root and url_base) + ([$ws:ident, $data_dir:ident, $incoming_dir:ident, $org_conn:ident, $url_base:ident, $bitrate:expr, $org_cmd:expr, $custom_assertfs:expr, $ingest_username_from:expr, $ws_user_override:expr, $storage_factory:expr] $($body:tt)*) => { + cs_main_test!(@impl [$ws, $data_dir, $incoming_dir, $org_conn, $url_base, $bitrate, $org_cmd, $custom_assertfs, $ingest_username_from, $ws_user_override, + |media_root: std::path::PathBuf, url_base: &str| { let f = $storage_factory; f(media_root, url_base) }] $($body)*) + }; + // Single implementation - storage_factory takes (media_root, url_base) + (@impl [$ws:ident, $data_dir:ident, $incoming_dir:ident, $org_conn:ident, $url_base:ident, $bitrate:expr, $org_cmd:expr, $custom_assertfs:expr, $ingest_username_from:expr, $ws_user_override:expr, $storage_factory:expr] $($body:tt)*) => { { let $data_dir = $custom_assertfs.unwrap_or(assert_fs::TempDir::new().unwrap()); let $incoming_dir = $data_dir.join("incoming"); std::fs::create_dir_all($incoming_dir.as_path()).unwrap(); // Run server + let port = portpicker::pick_unused_port().expect("No TCP ports free"); + let $url_base = format!("http://127.0.0.1:{}", port); + let ws_url = format!("{}/api/ws", &$url_base.replace("http", "ws")); let target_bitrate = $bitrate; let regex = validate_org_http_headers_regex("^X[-_]REMOTE[-_]").unwrap(); let grpc_server_bind = crate::grpc::grpc_server::make_grpc_server_bind(&None, &$data_dir)?; let (org_uri, _org_hdl) = prepare_organizer(&None, &$org_cmd, tracing::Level::DEBUG, false, &$data_dir.path())?; - // Pick the HTTP port as late as possible — right before binding — to keep the - // TOCTOU window between portpicker probing the port and warp actually binding it - // as small as possible. These tests start ~80 servers sequentially, so a wide - // window (e.g. spanning the organizer subprocess spawn above) flakes with EADDRINUSE. - let port = portpicker::pick_unused_port().expect("No TCP ports free"); - let url_base = format!("http://127.0.0.1:{}", port); - let ws_url = format!("{}/api/ws", &url_base.replace("http", "ws")); - let terminate_flag = Arc::new(AtomicBool::new(false)); let th = { let poll_interval = 0.1; let data_dir = $data_dir.path().to_path_buf(); - let url_base = url_base.clone(); + let url_base_for_storage = $url_base.clone(); let org_uri = org_uri.clone(); + let media_root = data_dir.join("videos"); + let storage = { let f = $storage_factory; f(media_root, &url_base_for_storage) }; let tf = terminate_flag.clone(); + let url_base_for_server = $url_base.clone(); thread::spawn(move || { - let mut clapshot = crate::ClapshotInit::init_and_spawn_workers(data_dir, true, url_base, vec![], "127.0.0.1".into(), port, org_uri.clone(), grpc_server_bind, 4, target_bitrate, poll_interval, "anonymous".to_string(), poll_interval*5.0, $ingest_username_from, "scripts/clapshot-transcode".to_string(), "scripts/clapshot-thumbnail".to_string(), "scripts/clapshot-transcode-decision".to_string(), None, "*".to_string(), regex, tf)?; + let mut clapshot = crate::ClapshotInit::init_and_spawn_workers(data_dir, true, url_base_for_server, vec![], "127.0.0.1".into(), port, org_uri.clone(), grpc_server_bind, 4, target_bitrate, poll_interval, "anonymous".to_string(), poll_interval*5.0, $ingest_username_from, "scripts/clapshot-transcode".to_string(), "scripts/clapshot-thumbnail".to_string(), "scripts/clapshot-transcode-decision".to_string(), None, "*".to_string(), regex, storage, tf)?; clapshot.wait_for_termination() })}; - assert!(wait_for_healthy(&url_base), "Server API never became healthy"); + assert!(wait_for_healthy(&$url_base), "Server API never became healthy"); tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async move { // Connect client @@ -160,7 +175,7 @@ mod integration_test tracing::info!("Waiting for run_clapshot() to terminate..."); let _ = th.join().unwrap(); } - } + }; } #[test] @@ -173,7 +188,7 @@ mod integration_test let metrics_snapshotter = metrics_recorder.snapshotter(); metrics_recorder.install().expect("Failed to install metrics recorder"); - cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, 2500_000, None, None, IngestUsernameFrom::FileOwner] + cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, url_base, 2500_000, None, None, IngestUsernameFrom::FileOwner] // Copy test file to incoming dir let mp4_file = "60fps-example.mp4"; data_dir.copy_from("src/tests/assets/", &[mp4_file]).unwrap(); @@ -239,7 +254,7 @@ mod integration_test #[traced_test] fn test_video_try_ingest_corrupted_video() -> anyhow::Result<()> { - cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, 500_000, None, None, IngestUsernameFrom::FileOwner] + cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, url_base, 500_000, None, None, IngestUsernameFrom::FileOwner] tracing::info!("WRITING CORRUPTED VIDEO"); // Copy test file to incoming dir @@ -261,7 +276,7 @@ mod integration_test // --- Transcoding tests --- pub struct WaitForReportResults { - pub media_file_id: String, + pub media_id: String, pub transcode_complete: bool, pub thumbs_complete: bool, pub got_progress_report: bool, @@ -279,7 +294,7 @@ mod integration_test check_file_outputs: Option<(PathBuf, String)>) -> WaitForReportResults { let mut res = WaitForReportResults { - media_file_id: String::new(), + media_id: String::new(), transcode_complete: false, thumbs_complete: false, got_progress_report: false, got_transcode_report: false, got_thumbnail_report: false, ts_cols: String::new(), ts_rows: String::new(), @@ -290,13 +305,13 @@ mod integration_test // Wait for the file to be ingested (timing depends on mediainfo, so poll). let msg = wait_for_user_msg(&mut ws, proto::user_message::Type::MediaFileAdded, 30).await; // notification to client (with upload folder info etc) let vid = msg.refs.unwrap().media_file_id.unwrap(); + res.media_id = vid.clone(); let msg = wait_for_user_msg(&mut ws, proto::user_message::Type::Ok, 30).await; // notification to user (in text) let vid2 = msg.refs.unwrap().media_file_id.unwrap(); assert_eq!(vid, vid2); assert!(vid.len() > 0); - res.media_file_id = vid.clone(); if expect_transcode { assert!(msg.details.unwrap().to_ascii_lowercase().contains("transcod")); } @@ -310,8 +325,12 @@ mod integration_test if still_waiting { match crate::api_server::test_utils::try_get_parsed::(&mut ws).await.map(|c| c.cmd).flatten() { Some(s2c::Cmd::ShowMessages(m)) => { - // Got progress report? - res.got_progress_report |= m.msgs.iter().any(|msg| msg.r#type == proto::user_message::Type::Progress as i32); + // Got progress report? Count explicit Progress topics as well as any + // message carrying a non-zero progress value (e.g. the final "Media transcoded." update). + res.got_progress_report |= m.msgs.iter().any(|msg| { + msg.r#type == proto::user_message::Type::Progress as i32 + || msg.progress.map(|p| p > 0.0).unwrap_or(false) + }); assert!(!m.msgs.iter().any(|msg| msg.r#type == proto::user_message::Type::Error as i32), "Got ERROR type message while waiting for transcode/thumbnail completion"); @@ -343,6 +362,8 @@ mod integration_test thread::sleep(Duration::from_millis(100)); } else { println!("...waiting done, expected reports received. Doing OpenNavigationPage ..."); + // Give any in-flight progress messages a moment to arrive before we request the page. + thread::sleep(Duration::from_millis(200)); send_server_cmd!(ws, OpenNavigationPage, OpenNavigationPage {..Default::default()}); break; } @@ -362,7 +383,10 @@ mod integration_test Some(s2c::Cmd::ShowMessages(m)) => { tracing::info!("Got ShowMessages (while waiting for ShowPage. Ignoring."); - res.got_progress_report |= m.msgs.iter().any(|msg| msg.r#type == proto::user_message::Type::Progress as i32); + res.got_progress_report |= m.msgs.iter().any(|msg| { + msg.r#type == proto::user_message::Type::Progress as i32 + || msg.progress.map(|p| p > 0.0).unwrap_or(false) + }); assert!(!m.msgs.iter().any(|msg| msg.r#type == proto::user_message::Type::Error as i32), "Got ERROR type message while waiting for ShowPage"); }, @@ -486,7 +510,7 @@ mod integration_test #[cfg(feature = "include_slow_tests")] fn test_video_mov_ingest_and_transcode() -> anyhow::Result<()> { - cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, 500_000, None, None, IngestUsernameFrom::FileOwner] + cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, url_base, 500_000, None, None, IngestUsernameFrom::FileOwner] // Copy test file to incoming dir let mov_file = "NASA_Red_Lettuce_excerpt.mov"; @@ -509,7 +533,7 @@ mod integration_test #[cfg(feature = "include_slow_tests")] fn test_video_12bit_dnxhr_alpha_ingest_and_transcode() -> anyhow::Result<()> { - cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, 500_000, None, None, IngestUsernameFrom::FileOwner] + cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, url_base, 500_000, None, None, IngestUsernameFrom::FileOwner] // Copy test file to incoming dir let mov_file = "alpha-test_dnxhr-444-12bit-dnxhr.mov"; @@ -534,7 +558,7 @@ mod integration_test // Also verifies that browser-compatible audio (AAC) is copied rather than re-encoded, // and that channel count is not forced to stereo. // Use a low target bitrate to force the transcode-decision script to transcode. - cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, 50_000, None, None, IngestUsernameFrom::FileOwner] + cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, url_base, 50_000, None, None, IngestUsernameFrom::FileOwner] let mp4_file = "extra-tracks.mp4"; data_dir.copy_from("src/tests/assets/", &[mp4_file]).unwrap(); std::fs::rename(data_dir.join(mp4_file), incoming_dir.join(mp4_file)).unwrap(); @@ -585,7 +609,7 @@ mod integration_test #[cfg(feature = "include_slow_tests")] fn test_audio_ingest_and_transcode() -> anyhow::Result<()> { - cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, 500_000, None, None, IngestUsernameFrom::FileOwner] + cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, url_base, 500_000, None, None, IngestUsernameFrom::FileOwner] // Copy test file to incoming dir let audio_file_name = "drunkards-special-short-mono.wav"; data_dir.copy_from("src/tests/assets/", &[audio_file_name]).unwrap(); @@ -624,7 +648,7 @@ mod integration_test #[cfg(feature = "include_slow_tests")] fn test_audio_transcode_updates_fps_from_output() -> anyhow::Result<()> { - cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, 500_000, None, None, IngestUsernameFrom::FileOwner] + cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, url_base, 500_000, None, None, IngestUsernameFrom::FileOwner] let audio_file_name = "sweep-tone.flac"; // shortest audio fixture (5s) data_dir.copy_from("src/tests/assets/", &[audio_file_name]).unwrap(); std::fs::rename(data_dir.join(audio_file_name), incoming_dir.join(audio_file_name)).unwrap(); @@ -632,7 +656,7 @@ mod integration_test let wait_res = wait_for_reports(&mut ws, true, false, false, Some((data_dir.path().into(), audio_file_name.into()))).await; // No thumbnail for audio // Open the file through the public API and inspect the metadata the client receives. - let media_file = open_media_file(&mut ws, &wait_res.media_file_id).await.media_file.unwrap(); + let media_file = open_media_file(&mut ws, &wait_res.media_id).await.media_file.unwrap(); let dur = media_file.duration.expect("media file has no duration metadata"); let fps: f64 = dur.fps.parse().unwrap_or_else(|_| panic!("fps is not a number: {:?}", dur.fps)); @@ -649,7 +673,7 @@ mod integration_test #[cfg(feature = "include_slow_tests")] fn test_mp3_full_integration() -> anyhow::Result<()> { - cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, 500_000, None, None, IngestUsernameFrom::FileOwner] + cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, url_base, 500_000, None, None, IngestUsernameFrom::FileOwner] // Copy the MP3 file to incoming dir and test full integration let audio_file_name = "Apollo11_countdown.mp3"; data_dir.copy_from("src/tests/assets/", &[audio_file_name]).unwrap(); @@ -694,6 +718,7 @@ mod integration_test file_path: test_file.clone(), user_id: "test_user".to_string(), cookies: HashMap::new(), + transcode_preference: crate::video_pipeline::TranscodePreference::Auto, }; let (tx, rx) = crossbeam_channel::unbounded(); @@ -738,7 +763,7 @@ mod integration_test #[traced_test] fn test_image_ingest_and_transcode() -> anyhow::Result<()> { - cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, 500_000, None, None, IngestUsernameFrom::FileOwner] + cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, url_base, 500_000, None, None, IngestUsernameFrom::FileOwner] let image_file_name = "NASA-48410_PIA25967_-_MAV_Test.jpeg"; data_dir.copy_from("src/tests/assets/", &[image_file_name]).unwrap(); std::fs::rename(data_dir.join(image_file_name), incoming_dir.join(image_file_name)).unwrap(); @@ -762,7 +787,7 @@ mod integration_test std::fs::copy("src/tests/assets/databases/clapshot-migration-test-1_v056.sqlite", &db_file) .expect("Failed to copy test DB for migration test"); - cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, 500_000, None, Some(temp_dir), IngestUsernameFrom::FileOwner] + cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, url_base, 500_000, None, Some(temp_dir), IngestUsernameFrom::FileOwner] let image_file_name = "NASA-48410_PIA25967_-_MAV_Test.jpeg"; data_dir.copy_from("src/tests/assets/", &[image_file_name]).unwrap(); std::fs::rename(data_dir.join(image_file_name), incoming_dir.join(image_file_name)).unwrap(); @@ -784,7 +809,7 @@ mod integration_test // Overwrite the test DB with one from assets dir, for migration testing on existing DB let db_file = temp_dir.path().join("clapshot.sqlite"); std::fs::copy("src/tests/assets/databases/clapshot-migration-test-1_v056.sqlite", &db_file).expect("Failed to copy test DB for migration test"); - cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, 500_000, Some(org_cmd), Some(temp_dir), IngestUsernameFrom::FileOwner] + cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, url_base, 500_000, Some(org_cmd), Some(temp_dir), IngestUsernameFrom::FileOwner] // If we get any client messages, Organizer migration was successful and API server was started wait_for_any_client_msg(&mut ws).await; } @@ -808,7 +833,7 @@ mod integration_test // Overwrite the test DB with one from assets dir, for migration testing on existing DB let db_file = temp_dir.path().join("clapshot.sqlite"); std::fs::copy("src/tests/assets/databases/clapshot-migration-test-2_v061.sqlite", &db_file).expect("Failed to copy test DB for migration test"); - cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, 500_000, Some(org_cmd), Some(temp_dir), IngestUsernameFrom::FileOwner] + cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, url_base, 500_000, Some(org_cmd), Some(temp_dir), IngestUsernameFrom::FileOwner] // If we get any client messages, Organizer migration was successful and API server was started wait_for_any_client_msg(&mut ws).await; } @@ -855,7 +880,7 @@ mod integration_test let test_names: Arc>> = Arc::new(Mutex::new(Vec::new())); { let test_names = test_names.clone(); - cs_main_test! {[_ws, data_dir, incoming_dir, org_conn, 500_000, Some(cmd.clone()), None, IngestUsernameFrom::FileOwner] + cs_main_test! {[_ws, data_dir, incoming_dir, org_conn, url_base, 500_000, Some(cmd.clone()), None, IngestUsernameFrom::FileOwner] match org_conn { Some(mut org_conn) => { match org_conn.list_tests(proto::Empty {}).await { @@ -899,7 +924,7 @@ mod integration_test let test_results = test_results.clone(); let log = log.clone(); - cs_main_test! {[_ws, data_dir, incoming_dir, org_conn, 500_000, Some(cmd.clone()), Some(temp_dir), IngestUsernameFrom::FileOwner] + cs_main_test! {[_ws, data_dir, incoming_dir, org_conn, url_base, 500_000, Some(cmd.clone()), Some(temp_dir), IngestUsernameFrom::FileOwner] match org_conn { Some(mut org_conn) => { match org_conn.run_test(org::RunTestRequest { test_name: test_name.clone() }).await { @@ -957,7 +982,7 @@ mod integration_test #[traced_test] fn test_ingest_username_from_file_owner() -> anyhow::Result<()> { - cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, 2500_000, None, None, IngestUsernameFrom::FileOwner] + cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, url_base, 2500_000, None, None, IngestUsernameFrom::FileOwner] // Copy test file to incoming dir (owned by current user) let mp4_file = "60fps-example.mp4"; data_dir.copy_from("src/tests/assets/", &[mp4_file]).unwrap(); @@ -987,7 +1012,7 @@ mod integration_test #[traced_test] fn test_ingest_username_from_folder_name() -> anyhow::Result<()> { - cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, 2500_000, None, None, IngestUsernameFrom::FolderName, Some("test_folder_user".to_string())] + cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, url_base, 2500_000, None, None, IngestUsernameFrom::FolderName, Some("test_folder_user".to_string())] // Create user folder structure with specific test username let current_user = whoami::username(); let username = "test_folder_user".to_string(); // Different from file owner - proves folder extraction works @@ -1022,7 +1047,7 @@ mod integration_test #[traced_test] fn test_ingest_username_from_folder_name_nested() -> anyhow::Result<()> { - cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, 2500_000, None, None, IngestUsernameFrom::FolderName, Some("test_nested_user".to_string())] + cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, url_base, 2500_000, None, None, IngestUsernameFrom::FolderName, Some("test_nested_user".to_string())] // Create folder structure with specific test username let current_user = whoami::username(); let username = "test_nested_user".to_string(); // Different from file owner - proves folder extraction works @@ -1052,4 +1077,603 @@ mod integration_test Ok(()) } + // ==================== S3/MinIO Integration Tests ==================== + + const TEST_BUCKET: &str = "clapshot-test"; + + /// Helper to manage a temporary MinIO instance for testing. + /// Spawns MinIO on a free port with a temp data directory. + /// Automatically cleans up when dropped. + struct TempMinIO { + process: std::process::Child, + endpoint: String, + port: u16, + _data_dir: assert_fs::TempDir, + } + + impl TempMinIO { + /// Start a new MinIO instance. Returns None if `minio` is not in PATH. + fn start() -> Option { + // Check if minio binary is available + if std::process::Command::new("minio") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_err() + { + tracing::warn!("MinIO binary not found in PATH - skipping S3 tests"); + return None; + } + + let port = portpicker::pick_unused_port().expect("No TCP ports free"); + let console_port = portpicker::pick_unused_port().expect("No TCP ports free for console"); + let data_dir = assert_fs::TempDir::new().expect("Failed to create temp dir for MinIO"); + + tracing::info!("Starting MinIO on port {} with data dir {:?}", port, data_dir.path()); + + let process = std::process::Command::new("minio") + .arg("server") + .arg(data_dir.path()) + .arg("--address") + .arg(format!("127.0.0.1:{}", port)) + .arg("--console-address") + .arg(format!("127.0.0.1:{}", console_port)) + .env("MINIO_ROOT_USER", "minioadmin") + .env("MINIO_ROOT_PASSWORD", "minioadmin") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("Failed to start MinIO"); + + let endpoint = format!("http://127.0.0.1:{}", port); + + // Wait for MinIO to be ready + let start = std::time::Instant::now(); + let timeout = Duration::from_secs(10); + loop { + if start.elapsed() > timeout { + tracing::error!("MinIO failed to start within timeout"); + return None; + } + if reqwest::blocking::get(format!("{}/minio/health/live", endpoint)) + .map(|r| r.status().is_success()) + .unwrap_or(false) + { + tracing::info!("MinIO is ready on {}", endpoint); + break; + } + thread::sleep(Duration::from_millis(100)); + } + + Some(TempMinIO { + process, + endpoint, + port, + _data_dir: data_dir, + }) + } + + /// Create an S3 client for this MinIO instance (for test verification) + fn s3_client(&self) -> aws_sdk_s3::Client { + use aws_sdk_s3::config::Region; + + let rt = tokio::runtime::Runtime::new().unwrap(); + let endpoint = self.endpoint.clone(); + rt.block_on(async move { + let config = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .endpoint_url(&endpoint) + .region(Region::new("us-east-1")) + .load() + .await; + let s3_config = aws_sdk_s3::config::Builder::from(&config) + .force_path_style(true) + .build(); + aws_sdk_s3::Client::from_conf(s3_config) + }) + } + + /// Create the test bucket + fn create_bucket(&self) -> anyhow::Result<()> { + let client = self.s3_client(); + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + client + .create_bucket() + .bucket(TEST_BUCKET) + .send() + .await + .map_err(|e| anyhow::anyhow!("Failed to create bucket: {}", e))?; + Ok(()) + }) + } + + /// Create a StorageBackend for this MinIO instance + fn storage_backend(&self, media_root: PathBuf, prefix: &str, url_base: &str) -> anyhow::Result { + StorageBackend::s3( + media_root, + TEST_BUCKET.to_string(), + Some(self.endpoint.clone()), + None, + prefix.to_string(), + Some(format!("{}/{}", self.endpoint, TEST_BUCKET)), + url_base.to_string(), + Duration::from_secs(3600), + ) + } + + /// Check if an object exists in S3 + fn object_exists(&self, key: &str) -> bool { + let client = self.s3_client(); + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + client + .head_object() + .bucket(TEST_BUCKET) + .key(key) + .send() + .await + .is_ok() + }) + } + + /// List all objects under a prefix (blocking version for non-async tests) + fn list_objects(&self, prefix: &str) -> Vec { + let client = self.s3_client(); + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(Self::list_objects_async(&client, prefix)) + } + + /// List all objects under a prefix (async version for use inside async contexts) + async fn list_objects_async(client: &aws_sdk_s3::Client, prefix: &str) -> Vec { + client + .list_objects_v2() + .bucket(TEST_BUCKET) + .prefix(prefix) + .send() + .await + .map(|r| r.contents().iter().filter_map(|o| o.key().map(String::from)).collect()) + .unwrap_or_default() + } + + /// Set up AWS env vars for SDK credential chain + fn setup_env_vars() { + std::env::set_var("AWS_ACCESS_KEY_ID", "minioadmin"); + std::env::set_var("AWS_SECRET_ACCESS_KEY", "minioadmin"); + std::env::set_var("AWS_REGION", "us-east-1"); + } + + /// Set up a complete test environment. Returns (storage, data_dir, prefix). + fn setup_test(&self) -> anyhow::Result<(StorageBackend, assert_fs::TempDir, String)> { + TempMinIO::setup_env_vars(); + self.create_bucket()?; + + let data_dir = assert_fs::TempDir::new()?; + let media_root = data_dir.path().join("videos"); + std::fs::create_dir_all(&media_root)?; + + let prefix = format!("test-{}", uuid::Uuid::new_v4()); + let url_base = "http://127.0.0.1:8080".to_string(); + let storage = self.storage_backend(media_root, &prefix, &url_base)?; + + Ok((storage, data_dir, prefix)) + } + } + + impl Drop for TempMinIO { + fn drop(&mut self) { + tracing::info!("Stopping MinIO on port {}", self.port); + let _ = self.process.kill(); + let _ = self.process.wait(); + } + } + + /// Tests S3 upload for both small files (simple PUT) and large files (multipart). + /// Also verifies progress callback and media_base_url. + #[test] + #[serial] + #[traced_test] + fn test_s3_storage_upload() -> anyhow::Result<()> { + let minio = match TempMinIO::start() { + Some(m) => m, + None => return Ok(()), // Skip if MinIO not available + }; + let (storage, data_dir, prefix) = minio.setup_test()?; + let media_root = data_dir.path().join("videos"); + + // Verify media_base_url is correct + let expected_url = format!("{}/{}/{}", minio.endpoint, TEST_BUCKET, prefix); + assert_eq!(storage.media_base_url(), expected_url); + + // Test 1: Small file upload (simple PUT, below 5MB threshold) + let small_dir = media_root.join("small-file"); + std::fs::create_dir_all(&small_dir)?; + let small_file = small_dir.join("small.mp4"); + std::fs::write(&small_file, b"small test content")?; + storage.upload_local_path(&small_file)?; + assert!(minio.object_exists(&format!("{}/small-file/small.mp4", prefix)), + "Small file should exist in S3"); + + // Test 2: Large file upload (multipart, 10MB > 5MB threshold) with progress + let large_dir = media_root.join("large-file"); + std::fs::create_dir_all(&large_dir)?; + let large_file = large_dir.join("large.mp4"); + std::fs::write(&large_file, vec![0u8; 10 * 1024 * 1024])?; + + let progress_values = Arc::new(std::sync::Mutex::new(Vec::new())); + let pv = progress_values.clone(); + let progress_cb: crate::storage::ProgressCallback = Arc::new(move |p| { + pv.lock().unwrap().push(p); + }); + storage.upload_with_progress(&large_file, Some(progress_cb))?; + + let progress = progress_values.lock().unwrap(); + assert!(!progress.is_empty(), "Progress should have been reported"); + assert!((progress.last().unwrap() - 1.0).abs() < 0.001, "Final progress should be ~1.0"); + assert!(minio.object_exists(&format!("{}/large-file/large.mp4", prefix)), + "Large file should exist in S3"); + + // Test 3: Presigned URL generation + let rt = tokio::runtime::Runtime::new()?; + let presigned = rt.block_on(storage.presigned_url("small-file", "small.mp4"))?; + assert!(presigned.contains("X-Amz-Signature"), "Presigned URL should contain signature"); + assert!(presigned.contains(&format!("{}/small-file/small.mp4", prefix)), + "Presigned URL should contain the S3 key"); + + Ok(()) + } + + /// Full E2E test: ingest video → transcode → upload to S3 + #[test] + #[serial] + #[traced_test] + #[cfg(feature = "include_slow_tests")] + fn test_s3_video_ingest_transcode_upload() -> anyhow::Result<()> { + TempMinIO::setup_env_vars(); + let minio = match TempMinIO::start() { + Some(m) => m, + None => return Ok(()), + }; + minio.create_bucket()?; + + let test_prefix = format!("test-e2e-{}", uuid::Uuid::new_v4()); + let minio_endpoint = minio.endpoint.clone(); + let prefix_clone = test_prefix.clone(); + + // Get S3 client before entering async context (avoids nested runtime) + let s3_client = minio.s3_client(); + + let storage_factory = move |media_root: PathBuf, url_base: &str| -> StorageBackend { + StorageBackend::s3( + media_root, TEST_BUCKET.to_string(), Some(minio_endpoint.clone()), + None, + prefix_clone.clone(), Some(format!("{}/{}", minio_endpoint, TEST_BUCKET)), + url_base.to_string(), Duration::from_secs(3600), + ).expect("Failed to create S3 storage backend") + }; + + cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, url_base, 500_000, None, None, IngestUsernameFrom::FileOwner, None, storage_factory] + // Ingest test video + let video_file_name = "NASA_Red_Lettuce_excerpt.mov"; + data_dir.copy_from("src/tests/assets/", &[video_file_name]).unwrap(); + std::fs::rename(data_dir.join(video_file_name), incoming_dir.join(video_file_name)).unwrap(); + + // Wait for processing (transcode + thumbnails) + let wait_res = wait_for_reports(&mut ws, true, true, true, None).await; + assert!(wait_res.transcode_complete, "Transcode did not complete"); + assert!(wait_res.thumbs_complete, "Thumbnails did not complete"); + + // Give S3 upload time to finish + thread::sleep(Duration::from_secs(2)); + + // Verify files in S3 (use async version to avoid nested runtime) + let objects = TempMinIO::list_objects_async(&s3_client, &format!("{}/{}/", test_prefix, wait_res.media_id)).await; + tracing::info!("S3 objects: {:?}", objects); + + assert!(objects.iter().any(|k| k.contains("video.mp4")), "Transcoded video missing: {:?}", objects); + assert!(objects.iter().any(|k| k.contains("/thumbs/")), "Thumbnails missing: {:?}", objects); + } + + Ok(()) + } + + /// E2E test that the /api/media redirect endpoint returns a presigned S3 URL. + #[test] + #[serial] + #[traced_test] + #[cfg(feature = "include_slow_tests")] + fn test_s3_media_redirect_endpoint() -> anyhow::Result<()> { + TempMinIO::setup_env_vars(); + let minio = match TempMinIO::start() { + Some(m) => m, + None => return Ok(()), + }; + minio.create_bucket()?; + + let test_prefix = format!("test-redirect-{}", uuid::Uuid::new_v4()); + let minio_endpoint = minio.endpoint.clone(); + let prefix_clone = test_prefix.clone(); + + let storage_factory = move |media_root: PathBuf, url_base: &str| -> StorageBackend { + StorageBackend::s3( + media_root, TEST_BUCKET.to_string(), Some(minio_endpoint.clone()), + None, + prefix_clone.clone(), Some(format!("{}/{}", minio_endpoint, TEST_BUCKET)), + url_base.to_string(), Duration::from_secs(3600), + ).expect("Failed to create S3 storage backend") + }; + + cs_main_test! {[ws, data_dir, incoming_dir, _org_conn, url_base, 500_000, None, None, IngestUsernameFrom::FileOwner, None, storage_factory] + // Ingest test video + let video_file_name = "NASA_Red_Lettuce_excerpt.mov"; + data_dir.copy_from("src/tests/assets/", &[video_file_name]).unwrap(); + std::fs::rename(data_dir.join(video_file_name), incoming_dir.join(video_file_name)).unwrap(); + + let wait_res = wait_for_reports(&mut ws, true, true, true, None).await; + assert!(wait_res.transcode_complete, "Transcode did not complete"); + assert!(wait_res.thumbs_complete, "Thumbnails did not complete"); + + // Give S3 upload time to finish + thread::sleep(Duration::from_secs(2)); + + // Request the redirect endpoint + let redirect_url = format!("{}/api/media/{}/video.mp4", url_base, wait_res.media_id); + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("Failed to build reqwest client"); + let resp = client + .get(&redirect_url) + .header("X-Remote-User-Id", whoami::username()) + .send() + .await + .expect("Failed to send redirect request"); + + assert_eq!(resp.status(), 302, "Expected redirect response"); + let location = resp.headers() + .get("Location") + .and_then(|v| v.to_str().ok()) + .expect("Missing Location header"); + assert!(location.contains("X-Amz-Signature"), "Location should be a presigned S3 URL"); + assert!(location.contains(&format!("{}/{}/video.mp4", test_prefix, wait_res.media_id)), + "Location should contain the correct S3 key"); + } + + Ok(()) + } + + #[test] + fn test_s3_storage_needs_remote_upload() -> anyhow::Result<()> { + let data_dir = assert_fs::TempDir::new()?; + let media_root = data_dir.path().join("videos"); + + // Local storage should not need remote upload + let local_storage = StorageBackend::local(media_root.clone(), "http://localhost:8080"); + assert!(!local_storage.needs_remote_upload()); + + Ok(()) + } + + #[test] + fn test_storage_media_url_local() { + let storage = StorageBackend::local(PathBuf::from("/tmp/videos"), "http://localhost:8080"); + assert_eq!(storage.media_url("abc123/video.mp4"), "http://localhost:8080/videos/abc123/video.mp4"); + assert_eq!(storage.media_url("/abc123/thumbs/thumb.webp"), "http://localhost:8080/videos/abc123/thumbs/thumb.webp"); + } + + #[test] + fn test_storage_media_url_s3() { + let storage = StorageBackend::s3( + PathBuf::from("/tmp/videos"), + "test-bucket".to_string(), + Some("http://minio.example.com".to_string()), + None, + "videos".to_string(), + None, + "http://localhost:8080".to_string(), + Duration::from_secs(3600), + ).expect("Failed to create S3 storage"); + + assert_eq!(storage.media_url("abc123/video.mp4"), "http://localhost:8080/api/media/abc123/video.mp4"); + } + + #[test] + fn test_storage_presigned_url_local_errors() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let storage = StorageBackend::local(PathBuf::from("/tmp/videos"), "http://localhost:8080"); + let res = rt.block_on(storage.presigned_url("abc123", "video.mp4")); + assert!(res.is_err()); + } + + #[test] + fn test_subtitle_from_proto3_strips_query_params() { + let subtitle = proto::Subtitle { + id: "42".to_string(), + media_file_id: "abc123".to_string(), + title: "Test".to_string(), + language_code: "en".to_string(), + playback_url: "http://localhost:8080/api/media/abc123/subs/foo.vtt?X-Amz-Signature=abc".to_string(), + orig_url: "http://localhost:8080/api/media/abc123/subs/orig/bar.srt".to_string(), + orig_filename: "bar.srt".to_string(), + added_time: Some(pbjson_types::Timestamp::from(chrono::Utc::now())), + time_offset: 0.0, + }; + + let model = crate::database::models::Subtitle::from_proto3(&subtitle).expect("from_proto3 failed"); + assert_eq!(model.filename, Some("foo.vtt".to_string())); + } + + #[test] + fn test_storage_media_base_url() { + let local = StorageBackend::local(PathBuf::from("/tmp/videos"), "http://localhost:8080"); + assert_eq!(local.media_base_url(), "http://localhost:8080/videos"); + + let s3 = StorageBackend::s3( + PathBuf::from("/tmp/videos"), + "test-bucket".to_string(), + Some("http://minio.example.com".to_string()), + None, + "videos".to_string(), + None, + "http://localhost:8080".to_string(), + Duration::from_secs(3600), + ).expect("Failed to create S3 storage"); + assert_eq!(s3.media_base_url(), "http://minio.example.com/test-bucket/videos"); + } + + #[test] + fn test_storage_upload_local_noop() { + let storage = StorageBackend::local(PathBuf::from("/tmp/videos"), "http://localhost:8080"); + let progress_called = Arc::new(std::sync::Mutex::new(false)); + let pc = progress_called.clone(); + let cb: crate::storage::ProgressCallback = Arc::new(move |p| { + assert!((p - 1.0).abs() < f32::EPSILON); + *pc.lock().unwrap() = true; + }); + storage.upload_with_progress(Path::new("/tmp/videos/foo.mp4"), Some(cb)).expect("local upload should be a no-op"); + assert!(*progress_called.lock().unwrap(), "progress callback should have been invoked"); + } + + #[test] + fn test_storage_upload_if_exists_local_noop() { + let storage = StorageBackend::local(PathBuf::from("/tmp/videos"), "http://localhost:8080"); + // Should not panic or error even for a missing path on local backend. + storage.upload_if_exists(Path::new("/tmp/videos/does-not-exist.mp4")); + } + + #[test] + fn test_storage_upload_required_local_noop() { + let storage = StorageBackend::local(PathBuf::from("/tmp/videos"), "http://localhost:8080"); + storage.upload_required(Path::new("/tmp/videos/foo.mp4")).expect("local upload_required should be a no-op"); + } + + #[test] + fn test_key_for_path_local() { + let storage = StorageBackend::local(PathBuf::from("/tmp/videos"), "http://localhost:8080"); + assert_eq!( + storage.key_for_path(Path::new("/tmp/videos/abc123/video.mp4")).unwrap(), + "videos/abc123/video.mp4" + ); + assert!(storage.key_for_path(Path::new("/outside/videos/foo.mp4")).is_err()); + } + + #[test] + fn test_key_for_path_s3() { + let storage = StorageBackend::s3( + PathBuf::from("/tmp/videos"), + "test-bucket".to_string(), + Some("http://minio.example.com".to_string()), + None, + "uploads".to_string(), + None, + "http://localhost:8080".to_string(), + Duration::from_secs(3600), + ).expect("Failed to create S3 storage"); + assert_eq!( + storage.key_for_path(Path::new("/tmp/videos/abc123/video.mp4")).unwrap(), + "uploads/abc123/video.mp4" + ); + } + + #[test] + fn test_guess_content_type() { + use std::path::Path; + assert_eq!(crate::storage::guess_content_type(Path::new("foo.mp4")), "video/mp4"); + assert_eq!(crate::storage::guess_content_type(Path::new("foo.mkv")), "video/x-matroska"); + assert_eq!(crate::storage::guess_content_type(Path::new("foo.webm")), "video/webm"); + assert_eq!(crate::storage::guess_content_type(Path::new("foo.mov")), "video/quicktime"); + assert_eq!(crate::storage::guess_content_type(Path::new("foo.webp")), "image/webp"); + assert_eq!(crate::storage::guess_content_type(Path::new("foo.png")), "image/png"); + assert_eq!(crate::storage::guess_content_type(Path::new("foo.jpg")), "image/jpeg"); + assert_eq!(crate::storage::guess_content_type(Path::new("foo.jpeg")), "image/jpeg"); + assert_eq!(crate::storage::guess_content_type(Path::new("foo.vtt")), "text/vtt"); + assert_eq!(crate::storage::guess_content_type(Path::new("foo.srt")), "application/x-subrip"); + assert_eq!(crate::storage::guess_content_type(Path::new("foo.xyz")), "application/octet-stream"); + } + + #[test] + #[serial] + #[traced_test] + fn test_s3_default_public_base_url() { + // When no endpoint and no public_base_url are provided, should default to AWS S3 URL. + let storage = StorageBackend::s3( + PathBuf::from("/tmp/videos"), + "my-bucket".to_string(), + None, + None, + "videos".to_string(), + None, + "http://localhost:8080".to_string(), + Duration::from_secs(3600), + ).expect("Failed to create AWS S3 storage"); + + assert_eq!(storage.media_base_url(), "https://my-bucket.s3.amazonaws.com/videos"); + } + + #[test] + #[serial] + #[traced_test] + fn test_s3_upload_empty_file() -> anyhow::Result<()> { + let minio = match TempMinIO::start() { + Some(m) => m, + None => return Ok(()), + }; + let (storage, data_dir, prefix) = minio.setup_test()?; + let media_root = data_dir.path().join("videos"); + + let dir = media_root.join("empty-file"); + std::fs::create_dir_all(&dir)?; + let empty_file = dir.join("empty.mp4"); + std::fs::write(&empty_file, b"")?; + + let progress_called = Arc::new(std::sync::Mutex::new(false)); + let pc = progress_called.clone(); + let cb: crate::storage::ProgressCallback = Arc::new(move |p| { + assert!((p - 1.0).abs() < f32::EPSILON); + *pc.lock().unwrap() = true; + }); + storage.upload_with_progress(&empty_file, Some(cb))?; + + assert!(*progress_called.lock().unwrap(), "progress callback should have been invoked for empty file"); + assert!(minio.object_exists(&format!("{}/empty-file/empty.mp4", prefix)), + "Empty file should exist in S3"); + + Ok(()) + } + + #[test] + #[serial] + #[traced_test] + fn test_s3_presigned_url_no_prefix() -> anyhow::Result<()> { + TempMinIO::setup_env_vars(); + let minio = match TempMinIO::start() { + Some(m) => m, + None => return Ok(()), + }; + let data_dir = assert_fs::TempDir::new()?; + let media_root = data_dir.path().join("videos"); + std::fs::create_dir_all(&media_root)?; + + minio.create_bucket()?; + let prefix = format!("test-noprefix-{}", uuid::Uuid::new_v4()); + let storage = StorageBackend::s3( + media_root, + TEST_BUCKET.to_string(), + Some(minio.endpoint.clone()), + None, + prefix.clone(), + Some(format!("{}/{}", minio.endpoint, TEST_BUCKET)), + "http://localhost:8080".to_string(), + Duration::from_secs(3600), + )?; + + let rt = tokio::runtime::Runtime::new()?; + let presigned = rt.block_on(storage.presigned_url("media-id", "video.mp4"))?; + assert!(presigned.contains("X-Amz-Signature"), "Presigned URL should contain signature"); + assert!(presigned.contains(&format!("{}/media-id/video.mp4", prefix)), + "Presigned URL should contain the S3 key with prefix"); + + Ok(()) + } + } diff --git a/server/src/video_pipeline/incoming_monitor.rs b/server/src/video_pipeline/incoming_monitor.rs index a2b44ba9..186af68a 100644 --- a/server/src/video_pipeline/incoming_monitor.rs +++ b/server/src/video_pipeline/incoming_monitor.rs @@ -119,7 +119,7 @@ pub fn run_forever( tracing::info!("Submitting for processing."); submission_time.insert(path.clone(), std::time::Instant::now()); if let Err(e) = incoming_sender.send( - super::IncomingFile {file_path: path.clone(), user_id: username, cookies: HashMap::new()}) { + super::IncomingFile {file_path: path.clone(), user_id: username, cookies: HashMap::new(), transcode_preference: super::TranscodePreference::Auto}) { tracing::error!(details=%e, "Failed to send incoming file to processing queue."); } }, diff --git a/server/src/video_pipeline/metadata_reader.rs b/server/src/video_pipeline/metadata_reader.rs index 11f0a353..dd250eb7 100644 --- a/server/src/video_pipeline/metadata_reader.rs +++ b/server/src/video_pipeline/metadata_reader.rs @@ -49,7 +49,8 @@ pub struct Metadata { pub fps: Decimal, pub bitrate: u32, pub metadata_all: String, - pub upload_cookies: HashMap // Cookies from the upload, not read from the file + pub upload_cookies: HashMap, // Cookies from the upload, not read from the file + pub transcode_preference: super::TranscodePreference, } pub type MetadataResult = Result; @@ -84,7 +85,7 @@ fn run_mediainfo( file: &PathBuf ) -> Result }, Err(e) => { tracing::debug!("Hard link failed ({}), falling back to symlink", e); - std::os::unix::fs::symlink(file, &link_path).map_err(|e| e.to_string())?; + std::os::unix::fs::symlink(std::fs::canonicalize(file).map_err(|e| e.to_string())?, &link_path).map_err(|e| e.to_string())?; } } @@ -165,7 +166,8 @@ fn extract_variables(json: serde_json::Value, args: &IncomingFile, get_file_s fps: Decimal::from_str(video_track["FrameRate"].as_str().ok_or("FPS not found")?).map_err(|_| "Invalid FPS".to_string())?, bitrate, metadata_all: json.to_string(), - upload_cookies: args.cookies.clone() + upload_cookies: args.cookies.clone(), + transcode_preference: args.transcode_preference, }) } @@ -181,7 +183,8 @@ fn extract_variables(json: serde_json::Value, args: &IncomingFile, get_file_s fps: Decimal::from_u8(0).unwrap(), bitrate: audio_track["BitRate"].as_str().ok_or("Bitrate not found")?.parse().map_err(|_| "Invalid bitrate".to_string())?, metadata_all: json.to_string(), - upload_cookies: args.cookies.clone() + upload_cookies: args.cookies.clone(), + transcode_preference: args.transcode_preference, }) } @@ -197,7 +200,8 @@ fn extract_variables(json: serde_json::Value, args: &IncomingFile, get_file_s fps: Decimal::from_u8(0).unwrap(), bitrate: 0, metadata_all: json.to_string(), - upload_cookies: args.cookies.clone() + upload_cookies: args.cookies.clone(), + transcode_preference: args.transcode_preference, }) } else { return Err("No video, audio or image track found".to_string()); @@ -224,6 +228,7 @@ pub(super) fn read_fps_and_frame_count(file: &PathBuf) -> Result<(Decimal, u32), file_path: file.clone(), user_id: String::new(), cookies: Default::default(), + transcode_preference: super::TranscodePreference::Auto, }; let md = extract_variables(json, &args, || file.metadata().map(|m| m.len()).map_err(|e| e.to_string()))?; Ok((md.fps, md.total_frames)) @@ -298,7 +303,8 @@ fn test_fixture(has_bitrate: bool, has_fps: bool) -> (IncomingFile, serde_json:: let args = IncomingFile { file_path: PathBuf::from("test.mp4"), user_id: "test_user".to_string(), - cookies: Default::default() + cookies: Default::default(), + transcode_preference: super::TranscodePreference::Auto, }; (args, json) diff --git a/server/src/video_pipeline/mod.rs b/server/src/video_pipeline/mod.rs index ebd46b0b..6c37e517 100644 --- a/server/src/video_pipeline/mod.rs +++ b/server/src/video_pipeline/mod.rs @@ -4,24 +4,24 @@ #![allow(unused_parens)] -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::io::Read; +use std::path::{Path, PathBuf}; use std::process::Command; use std::str::FromStr; -use std::sync::Arc; use std::sync::atomic::AtomicBool; +use std::sync::Arc; use std::thread; -use std::path::{PathBuf, Path}; use crossbeam_channel; -use crossbeam_channel::{Receiver, unbounded, select}; +use crossbeam_channel::{select, unbounded, Receiver}; use rust_decimal::prelude::{FromPrimitive, ToPrimitive}; use rust_decimal::Decimal; use tracing; -use anyhow::{anyhow, Context, bail}; -use sha2::{Sha256, Digest}; +use anyhow::{anyhow, bail, Context}; use hex; +use sha2::{Digest, Sha256}; pub mod incoming_monitor; pub mod metadata_reader; @@ -29,12 +29,13 @@ pub mod metadata_reader; mod cleanup_rejected; mod script_processor; -use metadata_reader::MetadataResult; use crate::api_server::{UserMessage, UserMessageTopic}; use crate::database::error::DBError; +use crate::database::{models, DbBasicQuery, DB}; +use crate::storage::StorageBackend; use crate::video_pipeline::metadata_reader::MediaType; use cleanup_rejected::clean_up_rejected_file; -use crate::database::{DB, models, DbBasicQuery}; +use metadata_reader::MetadataResult; #[derive(Debug, Clone)] pub enum IngestUsernameFrom { @@ -49,7 +50,10 @@ impl std::str::FromStr for IngestUsernameFrom { match s { "file-owner" => Ok(IngestUsernameFrom::FileOwner), "folder-name" => Ok(IngestUsernameFrom::FolderName), - _ => Err(format!("Invalid value '{}', must be 'file-owner' or 'folder-name'", s)), + _ => Err(format!( + "Invalid value '{}', must be 'file-owner' or 'folder-name'", + s + )), } } } @@ -59,12 +63,19 @@ pub const THUMB_SHEET_ROWS: u32 = 10; pub const THUMB_W: u32 = 160; pub const THUMB_H: u32 = 90; +#[derive(Debug, Clone, Copy)] +pub enum TranscodePreference { + Auto, + Force, + Skip, +} -#[derive (Clone, Debug)] +#[derive(Clone, Debug)] pub struct IncomingFile { pub file_path: PathBuf, pub user_id: String, - pub cookies: HashMap // Cookies from client, if this was an HTTP upload + pub cookies: HashMap, // Cookies from client, if this was an HTTP upload + pub transcode_preference: TranscodePreference, } #[derive(Debug, Clone)] @@ -75,13 +86,129 @@ pub struct DetailedMsg { pub user_id: String, } +fn send_progress_update( + user_msg_tx: &crossbeam_channel::Sender, + user_id: &str, + media_file_id: &str, + msg: &str, + progress: f32, +) { + let _ = user_msg_tx.send(UserMessage { + topic: UserMessageTopic::Progress, + msg: msg.to_string(), + details: None, + user_id: Some(user_id.to_string()), + media_file_id: Some(media_file_id.to_string()), + subtitle_id: None, + progress: Some(progress.clamp(0.0, 1.0)), + }); +} + +fn upload_to_storage_with_progress( + storage: &StorageBackend, + abs_path: &Path, + user_msg_tx: &crossbeam_channel::Sender, + user_id: &str, + media_file_id: &str, + label: &str, +) -> anyhow::Result<()> { + if !storage.needs_remote_upload() { + return storage.upload_local_path(abs_path); + } + + let tx = user_msg_tx.clone(); + let uid = user_id.to_string(); + let mid = media_file_id.to_string(); + let label = label.to_string(); + let callback = Arc::new(move |ratio: f32| { + send_progress_update(&tx, &uid, &mid, &label, ratio); + }); + + // Kick off the bar right away so the client shows it while we stream to S3. + callback(0.0); + storage.upload_with_progress(abs_path, Some(callback)) +} + +fn cleanup_local_media_dir(videos_dir: &Path, media_id: &str) -> anyhow::Result<()> { + let media_dir = videos_dir.join(media_id); + if !media_dir.exists() { + return Ok(()); + } + + // Remove main video files/symlink in the media root + let video_exts = ["mp4", "mkv", "webm", "mov", "avi"]; + if let Ok(entries) = std::fs::read_dir(&media_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + let remove = path + .extension() + .and_then(|e| e.to_str()) + .map(|ext| video_exts.contains(&ext.to_ascii_lowercase().as_str())) + .unwrap_or(false) + || path.file_name().and_then(|n| n.to_str()) == Some("video.mp4"); + + if remove { + if let Err(e) = std::fs::remove_file(&path) { + tracing::warn!(details=%e, file=?path, "Failed to remove local media file after upload"); + } + } + } + } + } + + for sub in ["orig", "thumbs"] { + let dir = media_dir.join(sub); + if dir.exists() { + if let Err(e) = std::fs::remove_dir_all(&dir) { + tracing::warn!(details=%e, dir=?dir, "Failed to remove local media directory after upload"); + } + } + } + + Ok(()) +} + +fn maybe_cleanup_local_media( + storage: &StorageBackend, + videos_dir: &Path, + media_id: &str, + transcode_pending: bool, + db: &DB, +) { + if !storage.needs_remote_upload() { + return; + } + + let ready = db + .conn() + .and_then(|mut conn| models::MediaFile::get(&mut conn, &media_id.to_string())) + .map(|mf| { + let thumbs_done = mf.thumbs_done.is_some(); + let transcode_done = mf.recompression_done.is_some() || !transcode_pending; + thumbs_done && transcode_done + }) + .unwrap_or(false); + + if ready { + if let Err(e) = cleanup_local_media_dir(videos_dir, media_id) { + tracing::warn!(details=%e, media_file_id=%media_id, "Failed to clean up local media after upload"); + } + } +} /// Calculate hash identifier (media_file_id) for the submitted files, /// based on filename, user_id, size and sample of the file contents. -fn calc_media_file_id(file_path: &PathBuf, user_id: &str, upload_cookies: HashMap) -> anyhow::Result { +fn calc_media_file_id( + file_path: &PathBuf, + user_id: &str, + upload_cookies: HashMap, +) -> anyhow::Result { let mut file_hash = Sha256::new(); - let fname = file_path.file_name() - .ok_or(anyhow!("Bad filename: {:?}", file_path))?.to_str() + let fname = file_path + .file_name() + .ok_or(anyhow!("Bad filename: {:?}", file_path))? + .to_str() .ok_or(anyhow!("Bad filename encoding {:?}", file_path))?; file_hash.update(fname.as_bytes()); @@ -101,7 +228,7 @@ fn calc_media_file_id(file_path: &PathBuf, user_id: &str, upload_cookies: HashMa // Read max 32k of contents let file = std::fs::File::open(file_path)?; - let mut buf = Vec::with_capacity(32*1024); + let mut buf = Vec::with_capacity(32 * 1024); file.take(32768u64).read_to_end(&mut buf)?; file_hash.update(&buf); @@ -118,22 +245,26 @@ fn ingest_media_file( md: &metadata_reader::Metadata, data_dir: &Path, media_files_dir: &Path, + storage: &StorageBackend, target_bitrate: u32, db: &DB, user_msg_tx: &crossbeam_channel::Sender, - cmpr_tx: &crossbeam_channel::Sender, - transcode_decision_script: &str) + transcode_decision_script: &str, + cmpr_tx: &crossbeam_channel::Sender) -> anyhow::Result { let _span = tracing::info_span!("INGEST_MEDIA", media_id = %media_id, user=md.user_id, - filename=%md.src_file.file_name().unwrap_or_default().to_string_lossy()).entered(); + filename=%md.src_file.file_name().unwrap_or_default().to_string_lossy()) + .entered(); tracing::info!("Ingesting file."); let src = PathBuf::from(&md.src_file); - if !src.is_file() { bail!("Source file not found: {:?}", src) } + if !src.is_file() { + bail!("Source file not found: {:?}", src) + } let dir_for_media_file = media_files_dir.join(&media_id); tracing::debug!("Media dir = {:?}", dir_for_media_file); @@ -146,23 +277,27 @@ fn ingest_media_file( let new_owner = &md.user_id; if &v.user_id == new_owner { tracing::info!("User already has this media file."); - user_msg_tx.send(UserMessage { - topic: UserMessageTopic::Ok, - msg: "Media file already exists".to_string(), - user_id: Some(new_owner.clone()), - media_file_id: None, // Don't pass media file id here, otherwise the pre-existing media would be deleted! - ..Default::default() - }).ok(); - - clean_up_rejected_file(&data_dir, &src, Some(media_id.into())).unwrap_or_else(|e| { - tracing::error!(details=?e, "Cleanup failed."); - }); + user_msg_tx + .send(UserMessage { + topic: UserMessageTopic::Ok, + msg: "Media file already exists".to_string(), + user_id: Some(new_owner.clone()), + media_file_id: None, // Don't pass media file id here, otherwise the pre-existing media would be deleted! + ..Default::default() + }) + .ok(); + + clean_up_rejected_file(&data_dir, &src, Some(media_id.into())).unwrap_or_else( + |e| { + tracing::error!(details=?e, "Cleanup failed."); + }, + ); return Ok(false); } else { bail!("Hash collision?!? Media '{media_id}' already owned by another user '{new_owner}'.") } - }, + } Err(DBError::NotFound()) => { // File exists, but not in DB. Remove files and reprocess. tracing::info!("Dir for '{media_id}' exists, but not in DB. Deleting old dir and reprocessing."); @@ -201,28 +336,43 @@ fn ingest_media_file( if !src_moved.exists() { bail!("Failed to move {:?} file to orig/", src_moved) } - let orig_filename = src.file_name().ok_or(anyhow!("Bad filename: {:?}", src))?.to_string_lossy().into_owned(); + let orig_filename = src + .file_name() + .ok_or(anyhow!("Bad filename: {:?}", src))? + .to_string_lossy() + .into_owned(); // Add to DB tracing::debug!("Adding media file to DB."); - models::MediaFile::insert(&mut db.conn()?, &models::MediaFileInsert { - id: media_id.to_string(), - user_id: md.user_id.clone(), - media_type: Some(md.media_type.as_ref().into()), - recompression_done: None, - thumbs_done: None, - has_thumbnail: None, - thumb_sheet_cols: None, - thumb_sheet_rows: None, - orig_filename: Some(orig_filename.clone()), - title: Some(orig_filename), - total_frames: Some(md.total_frames as i32), - duration: md.duration.to_f32(), - fps: Some(md.fps.to_string()), - raw_metadata_all: Some(md.metadata_all.clone()), - default_subtitle_id: None, - })?; + models::MediaFile::insert( + &mut db.conn()?, + &models::MediaFileInsert { + id: media_id.to_string(), + user_id: md.user_id.clone(), + media_type: Some(md.media_type.as_ref().into()), + recompression_done: None, + thumbs_done: None, + has_thumbnail: None, + thumb_sheet_cols: None, + thumb_sheet_rows: None, + orig_filename: Some(orig_filename.clone()), + title: Some(orig_filename), + total_frames: Some(md.total_frames as i32), + duration: md.duration.to_f32(), + fps: Some(md.fps.to_string()), + raw_metadata_all: Some(md.metadata_all.clone()), + default_subtitle_id: None, + }, + )?; + upload_to_storage_with_progress( + storage, + &src_moved, + user_msg_tx, + &md.user_id, + media_id, + "Uploading to storage", + )?; // Check if it needs recompressing by running the decision script fn run_transcode_decision_script( @@ -289,19 +439,44 @@ fn ingest_media_file( duration: md.duration, }; - let transcode_req = match run_transcode_decision_script(md, target_bitrate, transcode_decision_script)? { - Some((reason, new_bitrate)) => { - let video_dst_prefix = format!("transcoded_br{}_{}", new_bitrate, uuid::Uuid::new_v4()); - cmpr_tx.send(script_processor::CmprInput::Transcode { - video_dst_dir: dir_for_media_file.clone(), - video_dst_prefix, - video_bitrate: new_bitrate, - src: src.clone() - }).map(|_| (true, reason)).context("Error sending file to transcoding") - }, - None => { - tracing::info!("Media OK already, not transcoding."); - Ok((false, "".to_string())) + let transcode_req = match md.transcode_preference { + TranscodePreference::Skip => { + tracing::info!("Transcode preference is Skip, not transcoding."); + Ok((false, "User preference: skip transcode".to_string())) + } + TranscodePreference::Force => { + tracing::info!("Transcode preference is Force, transcoding."); + let video_dst_prefix = format!("transcoded_br{}_{}", target_bitrate, uuid::Uuid::new_v4()); + cmpr_tx + .send(script_processor::CmprInput::Transcode { + video_dst_dir: dir_for_media_file.clone(), + video_dst_prefix, + video_bitrate: target_bitrate, + src: src.clone(), + }) + .map(|_| (true, "User preference: force transcode".to_string())) + .context("Error sending file to transcoding") + } + TranscodePreference::Auto => { + match run_transcode_decision_script(md, target_bitrate, &transcode_decision_script) { + Ok(Some((reason, new_bitrate))) => { + let video_dst_prefix = format!("transcoded_br{}_{}", new_bitrate, uuid::Uuid::new_v4()); + cmpr_tx + .send(script_processor::CmprInput::Transcode { + video_dst_dir: dir_for_media_file.clone(), + video_dst_prefix, + video_bitrate: new_bitrate, + src: src.clone(), + }) + .map(|_| (true, reason)) + .context("Error sending file to transcoding") + } + Ok(None) => { + tracing::info!("Media OK already, not transcoding."); + Ok((false, "".to_string())) + } + Err(e) => Err(e), + } } }; @@ -318,18 +493,20 @@ fn ingest_media_file( media_type: md.media_type.clone(), path: src_moved.clone(), duration: md.duration, - } + }, }) { tracing::error!(details=?e, "Failed to send file to thumbnailing"); if let Err(e) = user_msg_tx.send(UserMessage { - topic: UserMessageTopic::Error, - msg: "Thumbnailing failed.".to_string(), - details: Some(format!("Error sending file to thumbnailing: {}", e)), - user_id: Some(md.user_id.clone()), - media_file_id: Some(media_id.to_string()), - subtitle_id: None, - progress: None - }) { tracing::error!(details=?e, "Failed to send user message") }; + topic: UserMessageTopic::Error, + msg: "Thumbnailing failed.".to_string(), + details: Some(format!("Error sending file to thumbnailing: {}", e)), + user_id: Some(md.user_id.clone()), + media_file_id: Some(media_id.to_string()), + subtitle_id: None, + progress: None, + }) { + tracing::error!(details=?e, "Failed to send user message") + }; }; }; @@ -340,25 +517,36 @@ fn ingest_media_file( user_msg_tx.send(UserMessage { topic: UserMessageTopic::MediaFileAdded, msg: String::new(), - details: Some(serde_json::to_string(&md.upload_cookies).map_err(|e| anyhow!("Error serializing cookies: {}", e))?), + details: Some( + serde_json::to_string(&md.upload_cookies) + .map_err(|e| anyhow!("Error serializing cookies: {}", e))?, + ), user_id: Some(md.user_id.clone()), media_file_id: Some(media_id.to_string()), subtitle_id: None, progress: None, })?; // Tell user in text also - tracing::debug!(transcode=do_transcode, reason=reason, "Media added to DB. Transcode"); + tracing::debug!( + transcode = do_transcode, + reason = reason, + "Media added to DB. Transcode" + ); user_msg_tx.send(UserMessage { topic: UserMessageTopic::Ok, - msg: "Media added.".to_string() + if do_transcode {" Transcoding..."} else {""}, - details: if do_transcode { Some(format!("Transcoding because {reason}")) } else { None }, + msg: "Media file added.".to_string() + if do_transcode { " Transcoding..." } else { "" }, + details: if do_transcode { + Some(format!("Transcoding because {reason}")) + } else { + None + }, user_id: Some(md.user_id.clone()), media_file_id: Some(media_id.to_string()), subtitle_id: None, progress: if do_transcode { Some(0.0) } else { None }, })?; Ok(do_transcode) - }, + } Err(e) => { tracing::error!(details=?e, "Media added to DB, but failed to send to transcoding."); user_msg_tx.send(UserMessage { @@ -375,13 +563,11 @@ fn ingest_media_file( } } - - - pub fn run_forever( db: Arc, terminate_flag: Arc, data_dir: PathBuf, + storage: StorageBackend, user_msg_tx: crossbeam_channel::Sender, poll_interval: f32, resubmit_delay: f32, @@ -404,14 +590,14 @@ pub fn run_forever( // Thread for incoming folder scanner let (_md_thread, from_md, to_md) = { - let (arg_sender, arg_recvr) = unbounded::(); - let (res_sender, res_recvr) = unbounded::(); + let (arg_sender, arg_recvr) = unbounded::(); + let (res_sender, res_recvr) = unbounded::(); - let th = thread::spawn(move || { - metadata_reader::run_forever(arg_recvr, res_sender, 4); - }); - (th, res_recvr, arg_sender) - }; + let th = thread::spawn(move || { + metadata_reader::run_forever(arg_recvr, res_sender, 4); + }); + (th, res_recvr, arg_sender) + }; // Thread for metadata reader let (mon_thread, from_mon, mon_exit) = { @@ -420,15 +606,18 @@ pub fn run_forever( let data_dir = data_dir.clone(); let th = thread::spawn(move || { - if let Err(e) = incoming_monitor::run_forever( - data_dir.clone(), - (data_dir.join("incoming") ).clone(), - poll_interval, resubmit_delay, - incoming_sender, - exit_recvr, - ingest_username_from) { - tracing::error!(details=?e, "Error from incoming monitor."); - }}); + if let Err(e) = incoming_monitor::run_forever( + data_dir.clone(), + (data_dir.join("incoming")).clone(), + poll_interval, + resubmit_delay, + incoming_sender, + exit_recvr, + ingest_username_from, + ) { + tracing::error!(details=?e, "Error from incoming monitor."); + } + }); (th, incoming_recvr, exit_sender) }; @@ -439,29 +628,48 @@ pub fn run_forever( let transcode_script_clone = transcode_script.clone(); let thumbnail_script_clone = thumbnail_script.clone(); thread::spawn(move || { - script_processor::run_forever(cmpr_in_rx, cmpr_out_tx, cmpr_prog_tx, n_workers, transcode_script_clone, thumbnail_script_clone); + script_processor::run_forever( + cmpr_in_rx, + cmpr_out_tx, + cmpr_prog_tx, + n_workers, + transcode_script_clone, + thumbnail_script_clone, + ); }); - // Migration from older version: find a media file that is missing thumbnail sheet - fn legacy_thumbnail_next_media_file(db: &DB, videos_dir: &PathBuf, cmpr_in: &mut crossbeam_channel::Sender) -> Option { + let mut transcode_pending: HashSet = HashSet::new(); - let candidates = db.conn() + // Migration from older version: find a media file that is missing thumbnail sheet + fn legacy_thumbnail_next_media_file( + db: &DB, + videos_dir: &PathBuf, + cmpr_in: &mut crossbeam_channel::Sender, + ) -> Option { + let candidates = db + .conn() .and_then(|mut conn| models::MediaFile::get_all_with_missing_thumbnails(&mut conn)) - .map_err(|e| { tracing::error!(details=?e, "DB: Failed to get media files without thumbnails."); }).ok()?; + .map_err(|e| { + tracing::error!(details=?e, "DB: Failed to get media files without thumbnails."); + }) + .ok()?; if let Some(v) = candidates.first() { tracing::info!(id=%v.id, "Found legacy media file that needs thumbnailing."); let media_file_path = if v.recompression_done.is_some() { - Some(videos_dir.join(&v.id).join("video.mp4")) - } else { - match v.orig_filename { - Some(ref orig_filename) => Some(videos_dir.join(&v.id).join("orig").join(orig_filename)), - None => { - tracing::error!(media_file_id=%v.id, "Legacy thumbnailing failed. Original filename missing and not recompressed."); - None - }} - }; + Some(videos_dir.join(&v.id).join("video.mp4")) + } else { + match v.orig_filename { + Some(ref orig_filename) => { + Some(videos_dir.join(&v.id).join("orig").join(orig_filename)) + } + None => { + tracing::error!(media_file_id=%v.id, "Legacy thumbnailing failed. Original filename missing and not recompressed."); + None + } + } + }; match media_file_path { Some(file_path) => { @@ -477,23 +685,24 @@ pub fn run_forever( media_file_id: v.id.clone(), media_type, path: file_path, - duration: Decimal::from_f32(v.duration.unwrap_or(0.0)).unwrap_or_default(), + duration: Decimal::from_f32(v.duration.unwrap_or(0.0)) + .unwrap_or_default(), }, }; cmpr_in.send(req).unwrap_or_else(|e| { tracing::error!(details=?e, "Error sending legacy thumbnailing request to compressor."); }); return Some(v.id.clone()); - }, + } _ => { tracing::error!(media_file_id=%v.id, "Legacy thumbnailing failed. User ID or orig filename missing."); - }, + } } } None } - let mut legacy_media_file_now_thumnailing = legacy_thumbnail_next_media_file(&db, &media_files_dir, &mut cmpr_in_tx.clone()); - + let mut legacy_media_file_now_thumnailing = + legacy_thumbnail_next_media_file(&db, &media_files_dir, &mut cmpr_in_tx.clone()); let _span = tracing::info_span!("PIPELINE").entered(); loop { @@ -503,7 +712,12 @@ pub fn run_forever( match msg { Ok(msg) => { tracing::debug!("Got upload result. Submitting it for processing. {:?}", msg); - to_md.send(IncomingFile {file_path: msg.file_path.clone(),user_id: msg.user_id, cookies: msg.cookies }).unwrap_or_else(|e| { + to_md.send(IncomingFile { + file_path: msg.file_path.clone(), + user_id: msg.user_id, + cookies: msg.cookies, + transcode_preference: msg.transcode_preference, + }).unwrap_or_else(|e| { tracing::error!("Error sending file to metadata reader: {:?}", e); clean_up_rejected_file(&data_dir, &msg.file_path, None).unwrap_or_else(|e| { tracing::error!("Cleanup of '{:?}' failed: {:?}", &msg.file_path, e); @@ -531,7 +745,7 @@ pub fn run_forever( })) }, Ok(vid) => { - let ing_res = ingest_media_file(&vid, &md, &data_dir, &media_files_dir, target_bitrate, &db, &user_msg_tx, &cmpr_in_tx, &transcode_decision_script).map_err(|e| { + let ing_res = ingest_media_file(&vid, &md, &data_dir, &media_files_dir, &storage, target_bitrate, &db, &user_msg_tx, &transcode_decision_script, &cmpr_in_tx).map_err(|e| { DetailedMsg { msg: "Media ingestion failed".into(), details: e.to_string(), @@ -544,6 +758,13 @@ pub fn run_forever( } MetadataResult::Err(e) => (None, Err(e)) }; + if let (Some(vid), Ok(do_transcode)) = (&vid, &ing_res) { + if *do_transcode { + transcode_pending.insert(vid.clone()); + } else { + transcode_pending.remove(vid); + } + } // Relay errors, if any. // No need to send ok message here, variations of it are sent from ingest_media_file(). if let Err(e) = ing_res { @@ -616,6 +837,12 @@ pub fn run_forever( { let videos_dir = media_files_dir.clone(); let vid = logs.media_file_id.clone(); + let storage = storage.clone(); + let cleanup_vid = vid.clone(); + let cleanup_storage = storage.clone(); + let cleanup_videos_dir = videos_dir.clone(); + let cleanup_db = db.clone(); + transcode_pending.remove(&vid); tracing::info!(media_file=%vid, log_info=%logs.stdout, "Transcoding completed"); @@ -644,6 +871,17 @@ pub fn run_forever( tracing::error!(details=%e, "Failed to create symlink {:?} -> {:?}", symlink_path, video_dst); return false; } + if let Err(e) = upload_to_storage_with_progress( + &storage, + &symlink_path, + &utx, + &user_id, + &vid, + "Uploading transcoded video", + ) { + tracing::error!(details=%e, "Failed to upload transcoded file to object storage"); + return false; + } // Refresh fps/total_frames from the transcoded output. The source file's // values are wrong when the playable video is synthesized (audio waveform @@ -691,12 +929,21 @@ pub fn run_forever( subtitle_id: None, progress: Some(1.0) }).unwrap_or_else(|e| { tracing::error!(details=%e, "Error sending user message"); }); + + maybe_cleanup_local_media( + &cleanup_storage, + &cleanup_videos_dir, + &cleanup_vid, + transcode_pending.contains(&cleanup_vid), + &cleanup_db, + ); }, ThumbsSuccess { thumb_dir, thumb_sheet_dims, logs } => { let videos_dir = media_files_dir.clone(); let vid = logs.media_file_id.clone(); + let storage = storage.clone(); let mut db_errors = false; // Thumbnails (and/or sheet) done? @@ -722,6 +969,12 @@ pub fn run_forever( } } } + if let Some(dir) = thumb_dir { + storage.upload_if_exists(&dir.join("thumb.webp")); + if let Some((sheet_cols, sheet_rows)) = thumb_sheet_dims { + storage.upload_if_exists(&dir.join(format!("sheet-{}x{}.webp", sheet_cols, sheet_rows))); + } + } // Send MediaFileUpdated message to user user_msg_tx.send(UserMessage { @@ -742,6 +995,14 @@ pub fn run_forever( tracing::error!(details=%e, "Error storing thumbs_done in DB"); } } + + maybe_cleanup_local_media( + &storage, + &videos_dir, + &vid, + transcode_pending.contains(&vid), + &db, + ); }, TranscodeFailure { logs, .. } | @@ -779,7 +1040,7 @@ pub fn run_forever( drop(mon_exit); terminate_flag.store(true, std::sync::atomic::Ordering::Relaxed); match mon_thread.join() { - Ok(_) => {}, + Ok(_) => {} Err(e) => { tracing::error!("Error waiting for monitor thread to exit: {:?}", e); } diff --git a/server/src/video_pipeline/script_processor.rs b/server/src/video_pipeline/script_processor.rs index a8c0ecab..2377feca 100644 --- a/server/src/video_pipeline/script_processor.rs +++ b/server/src/video_pipeline/script_processor.rs @@ -360,7 +360,7 @@ fn run_transcode_script(src: &CmprInputSource, output_dir: PathBuf, output_prefi }, None => { tracing::debug!("Progress pipe EOF. Sleeping..."); - std::thread::sleep(std::time::Duration::from_millis(250)); + std::thread::sleep(std::time::Duration::from_millis(50)); } Some(Ok(l)) => { tracing::trace!(chunk=%l, "Got script progress line.");