Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,16 @@ PROVIDER_TIMEOUT_MS=45000

# =============================================================================
# Worker tuning
#
# WORKER_CLAIM_BATCH_SIZE is reserved: the worker loop currently claims one
# job at a time. This key is accepted for forward compatibility with a future
# batch-claiming path and is otherwise inert.
# WORKER_RECLAIM_BATCH_SIZE caps how many expired leases are reclaimed per
# reclaim tick.
# =============================================================================
WORKER_ID=worker-1
WORKER_CLAIM_BATCH_SIZE=5
WORKER_RECLAIM_BATCH_SIZE=25
WORKER_LEASE_SECONDS=60
WORKER_POLL_MS=2000
WORKER_HEARTBEAT_MS=15000
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,5 @@ Auth is implemented — see `docs/auth-plan.md` for the current auth status and
- Mutations require `Idempotency-Key` header
- Processing phases are monotonic (rank only moves forward)
- Inbound and outbound webhooks use timestamped HMAC headers
- The worker loop claims one job at a time despite `WORKER_CLAIM_BATCH_SIZE` existing in config
- The worker loop claims one job at a time; `WORKER_CLAIM_BATCH_SIZE` is reserved/dormant and accepted only for forward compatibility. The reclaim tick uses `WORKER_RECLAIM_BATCH_SIZE`.
- Soft delete with delayed `cleanup_artifacts` job (5 min)
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,12 @@ That starts:
5. worker queues `transcribe_video`, then `generate_ai` when eligible
6. frontend polls status and shows playback, transcript, edits, and enrichments

## Tooling


## Where to look next

- [docs/system.md](docs/system.md) — runtime topology, architecture decisions, and capacity guidance
- [docs/development.md](docs/development.md) — run, debug, incident response, and safe repo changes
- [docs/contracts.md](docs/contracts.md) — API/webhook contracts, versioning stance, and contract changelog
- [docs/status.md](docs/status.md) — current gaps and next improvement areas
- [docs/auth-plan.md](docs/auth-plan.md) — current auth status and constraints
- [docs/review-auth-system.md](docs/review-auth-system.md) — auth review notes and follow-up suggestions
- [docs/review-auth-system.md](docs/review-auth-system.md) — dated auth-system code-review snapshot
- [docs/review-2026-04-10.md](docs/review-2026-04-10.md) — dated full-repo review + changelog (most recent)
3 changes: 2 additions & 1 deletion apps/web-api/src/lib/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ export {
// ---------------------------------------------------------------------------

export function getS3ClientAndBucket() {
const publicEndpoint = process.env.S3_PUBLIC_ENDPOINT ?? "http://localhost:9000";
// Default aligned with packages/config (host-mapped MinIO API port in docker-compose).
const publicEndpoint = process.env.S3_PUBLIC_ENDPOINT ?? "http://localhost:8922";
const signingEndpoint = publicEndpoint;
const region = process.env.S3_REGION ?? "us-east-1";
const accessKeyId = process.env.S3_ACCESS_KEY;
Expand Down
40 changes: 18 additions & 22 deletions apps/web-api/src/routes/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import type { Logger } from "@cap/logger";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// Minimal shape the auth routes reach for; cast through `unknown` to avoid
// pulling in every Logger method in this test mock.
const mockServiceLogger = (): Logger =>
({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
withContext: vi.fn(),
logger: {},
context: {},
logRequest: vi.fn()
}) as unknown as Logger;

const queryMock = vi.fn();
const verifyPasswordMock = vi.fn();
const signTokenMock = vi.fn(() => "signed-token");
Expand Down Expand Up @@ -41,17 +57,7 @@ describe("authRoutes login hardening", () => {
verifyPasswordMock.mockResolvedValue(false);

const app = Fastify();
app.decorate("serviceLogger", {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
withContext: vi.fn(),
logger: {},
context: {},
logRequest: vi.fn()
} as any);
app.decorate("serviceLogger", mockServiceLogger());
await app.register(cookie);
const { authRoutes } = await import("./auth.js");
await app.register(authRoutes);
Expand Down Expand Up @@ -88,17 +94,7 @@ describe("authRoutes login hardening", () => {
.mockResolvedValueOnce(true);

const app = Fastify();
app.decorate("serviceLogger", {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
withContext: vi.fn(),
logger: {},
context: {},
logRequest: vi.fn()
} as any);
app.decorate("serviceLogger", mockServiceLogger());
await app.register(cookie);
const { authRoutes } = await import("./auth.js");
await app.register(authRoutes);
Expand Down
9 changes: 5 additions & 4 deletions apps/web/e2e/layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ test.describe("Responsive layout", () => {
await expect(
page.getByRole("heading", { name: "Building APIs: Architecture and Best Practices", exact: true })
).toBeVisible();
await expect(page.getByRole("button", { name: "Notes", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Summary", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Transcript", exact: true })).toBeVisible();
// VideoRail tabs are now proper ARIA tabs (role="tab"), not plain buttons.
await expect(page.getByRole("tab", { name: "Notes", exact: true })).toBeVisible();
await expect(page.getByRole("tab", { name: "Summary", exact: true })).toBeVisible();
await expect(page.getByRole("tab", { name: "Transcript", exact: true })).toBeVisible();
await expect(page.getByText("Generated by Cap5 AI")).toBeVisible();
await expect(page.getByRole("heading", { name: "Chapters", exact: true })).toBeVisible();
});
Expand All @@ -32,7 +33,7 @@ test.describe("Responsive layout", () => {
await expect(
page.getByRole("heading", { name: "Building APIs: Architecture and Best Practices", exact: true })
).toBeVisible();
await expect(page.getByRole("button", { name: "Transcript", exact: true })).toBeVisible();
await expect(page.getByRole("tab", { name: "Transcript", exact: true })).toBeVisible();

await page.getByRole("button").nth(1).click();
await expect(page.getByRole("link", { name: "Home" })).toBeVisible();
Expand Down
22 changes: 13 additions & 9 deletions apps/web/e2e/mock-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,20 @@ export const MOCK_VIDEO_STATUS = {
language: "en",
vttKey: "cap5/00000000-0000-0000-0000-000000000001/transcript.vtt",
text: "Welcome to this demonstration. Today we cover API architecture. Let us begin with the basics.",
speakerLabels: {
"0": "Host",
"1": "Guest",
},
segments: [
{ startSeconds: 0, endSeconds: 5, text: "Welcome to this demonstration." },
{ startSeconds: 5, endSeconds: 12, text: "Today we cover API architecture." },
{ startSeconds: 12, endSeconds: 20, text: "Let us begin with the basics." },
{ startSeconds: 20, endSeconds: 28, text: "First, we define our endpoints clearly." },
{ startSeconds: 28, endSeconds: 36, text: "Authentication is the next consideration." },
{ startSeconds: 36, endSeconds: 44, text: "We use JSON web tokens for auth." },
{ startSeconds: 44, endSeconds: 52, text: "Rate limiting protects against abuse." },
{ startSeconds: 52, endSeconds: 60, text: "Finally, logging ties everything together." },
{ startSeconds: 60, endSeconds: 68, text: "In summary, good APIs require planning." }
{ startSeconds: 0, endSeconds: 5, text: "Welcome to this demonstration.", speaker: 0 },
{ startSeconds: 5, endSeconds: 12, text: "Today we cover API architecture.", speaker: 1 },
{ startSeconds: 12, endSeconds: 20, text: "Let us begin with the basics.", speaker: null },
{ startSeconds: 20, endSeconds: 28, text: "First, we define our endpoints clearly.", speaker: 0 },
{ startSeconds: 28, endSeconds: 36, text: "Authentication is the next consideration.", speaker: 1 },
{ startSeconds: 36, endSeconds: 44, text: "We use JSON web tokens for auth.", speaker: 0 },
{ startSeconds: 44, endSeconds: 52, text: "Rate limiting protects against abuse.", speaker: 1 },
{ startSeconds: 52, endSeconds: 60, text: "Finally, logging ties everything together.", speaker: 0 },
{ startSeconds: 60, endSeconds: 68, text: "In summary, good APIs require planning.", speaker: 1 }
]
},
aiOutput: {
Expand Down
69 changes: 66 additions & 3 deletions apps/web/e2e/player.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ test.describe("Video watch page", () => {
await page.goto(VIDEO_URL);
await page.waitForLoadState("networkidle");

await expect(page.getByRole("button", { name: "Transcript", exact: true })).toHaveClass(/rail-tab-active/);
await expect(page.getByRole("tab", { name: "Transcript", exact: true })).toHaveAttribute("aria-selected", "true");
await expect(page.getByPlaceholder("Search transcript…")).toBeVisible();
await expect(page.getByRole("button", { name: "Current", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Original", exact: true })).toBeVisible();
Expand All @@ -24,9 +24,9 @@ test.describe("Video watch page", () => {
await page.goto(VIDEO_URL);
await page.waitForLoadState("networkidle");

await page.getByRole("button", { name: "Summary", exact: true }).click();
await page.getByRole("tab", { name: "Summary", exact: true }).click();

const summaryPanel = page.locator(".rail-tab-panel-enter").filter({
const summaryPanel = page.getByRole("tabpanel", { name: "Summary", exact: true }).filter({
has: page.getByText("Generated by Cap5 AI"),
});

Expand All @@ -52,4 +52,67 @@ test.describe("Video watch page", () => {
await expect(chapterSection.getByRole("button", { name: /00:20 define endpoints clearly/i })).toBeVisible();
await expect(chapterSection.getByRole("button", { name: /rate limiting/i })).toBeVisible();
});

test("selected-speaker playback skips deselected speakers and persists per video", async ({ page }) => {
await page.goto(VIDEO_URL);
await page.waitForLoadState("networkidle");

await page.locator("video").evaluate((video) => {
let currentTime = 0;
let paused = false;

Object.defineProperty(video, "duration", {
configurable: true,
get: () => 68,
});
Object.defineProperty(video, "currentTime", {
configurable: true,
get: () => currentTime,
set: (value: number) => {
currentTime = value;
},
});
Object.defineProperty(video, "paused", {
configurable: true,
get: () => paused,
});
Object.defineProperty(video, "pause", {
configurable: true,
value: () => {
paused = true;
},
});
Object.defineProperty(video, "play", {
configurable: true,
value: async () => {
paused = false;
},
});

video.dispatchEvent(new Event("loadedmetadata"));
});

const speakerChips = page.locator("button.speaker-filter-chip");
await speakerChips.filter({ hasText: "Guest" }).click();

await expect(page.getByText("Today we cover API architecture.")).toHaveCount(0);
await expect(page.getByText("Let us begin with the basics.")).toBeVisible();
await expect(page.getByText("1 of 2 selected")).toBeVisible();

await speakerChips.filter({ hasText: "Host" }).click();

await expect(page.getByText("No speakers selected.", { exact: true })).toBeVisible();
await expect(page.getByText("No speakers selected. Re-enable at least one speaker to resume filtered playback.")).toBeVisible();

const isPaused = await page.locator("video").evaluate((video) => video.paused);
expect(isPaused).toBe(true);

await page.reload();
await page.waitForLoadState("networkidle");

await expect(page.getByText("No speakers selected.", { exact: true })).toBeVisible();
await expect(page.getByText("Welcome to this demonstration.")).toHaveCount(0);
await expect(page.getByText("Today we cover API architecture.")).toHaveCount(0);
await expect(page.getByText("Let us begin with the basics.")).toBeVisible();
});
});
24 changes: 24 additions & 0 deletions apps/web/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,26 @@
// Global test setup
// Extend expect with jest-dom matchers if needed in future
if (typeof window !== "undefined" && typeof window.localStorage?.clear !== "function") {
const storage = new Map<string, string>();
const mockLocalStorage = {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, String(value));
},
removeItem: (key: string) => {
storage.delete(key);
},
clear: () => {
storage.clear();
},
key: (index: number) => Array.from(storage.keys())[index] ?? null,
get length() {
return storage.size;
},
};

Object.defineProperty(window, "localStorage", {
configurable: true,
value: mockLocalStorage,
});
}
64 changes: 64 additions & 0 deletions apps/web/src/components/PlayerCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { PlayerCard } from "./PlayerCard";

describe("PlayerCard speaker filtering", () => {
it("shows an empty-state message and pauses playback when all speakers are deselected", () => {
const { container, rerender } = render(
<PlayerCard
videoUrl="https://example.com/video.mp4"
thumbnailUrl={null}
seekRequest={null}
onPlaybackTimeChange={vi.fn()}
onDurationChange={vi.fn()}
chapters={[]}
onSeekToSeconds={vi.fn()}
transcriptSegments={[
{ startSeconds: 0, endSeconds: 2, speaker: 0 },
{ startSeconds: 2, endSeconds: 4, speaker: 1 },
]}
selectedSpeakerIds={new Set([0, 1])}
speakerFilteringActive={false}
allSpeakersDeselected={false}
/>,
);

const video = container.querySelector("video") as HTMLVideoElement;
let paused = false;
Object.defineProperty(video, "paused", {
configurable: true,
get: () => paused,
});
const pauseSpy = vi.fn(() => {
paused = true;
});
Object.defineProperty(video, "pause", {
configurable: true,
value: pauseSpy,
});

rerender(
<PlayerCard
videoUrl="https://example.com/video.mp4"
thumbnailUrl={null}
seekRequest={null}
onPlaybackTimeChange={vi.fn()}
onDurationChange={vi.fn()}
chapters={[]}
onSeekToSeconds={vi.fn()}
transcriptSegments={[
{ startSeconds: 0, endSeconds: 2, speaker: 0 },
{ startSeconds: 2, endSeconds: 4, speaker: 1 },
]}
selectedSpeakerIds={new Set<number>()}
speakerFilteringActive={true}
allSpeakersDeselected={true}
/>,
);

expect(pauseSpy).toHaveBeenCalledTimes(1);
expect(
screen.getByText("No speakers selected. Re-enable at least one speaker to resume filtered playback."),
).toBeTruthy();
});
});
Loading
Loading