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
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,10 @@ dist
build
.git
data
.tmp
coverage
playwright-report
test-results
.env
.env.*
*.log
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ VITE_API_BASE_URL=http://localhost:4000
YT_DLP_BIN=C:\path\to\yt-dlp.exe
# Optional. The API automatically passes its current Node runtime to yt-dlp.
YT_DLP_JS_RUNTIME=node:C:\Program Files\nodejs\node.exe
# Optional local caption generation. Leave unset unless whisper.cpp is installed.
# WHISPER_CPP_BIN=C:\path\to\whisper-cli.exe
# WHISPER_CPP_MODEL=C:\path\to\ggml-base.en.bin
64 changes: 64 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,67 @@ jobs:
test-results/
if-no-files-found: ignore
retention-days: 7

docker-smoke:
runs-on: ubuntu-latest
timeout-minutes: 25

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Print Docker versions
run: |
docker --version
docker compose version

- name: Build Docker stack
run: docker compose build --no-cache

- name: Start Docker stack
run: docker compose up -d

- name: Wait for healthy services
run: |
for attempt in {1..30}; do
api_status="$(docker inspect --format='{{.State.Health.Status}}' "$(docker compose ps -q api)")"
web_status="$(docker inspect --format='{{.State.Health.Status}}' "$(docker compose ps -q web)")"
echo "Attempt ${attempt}: api=${api_status} web=${web_status}"
if [ "${api_status}" = "healthy" ] && [ "${web_status}" = "healthy" ]; then
exit 0
fi
sleep 2
done
exit 1

- name: Verify Docker endpoints
run: |
node - <<'NODE'
const checks = [
["API health", "http://127.0.0.1:4000/health"],
["API readiness", "http://127.0.0.1:4000/ready"],
["Web root", "http://127.0.0.1:5173/"]
];
for (const [label, url] of checks) {
const response = await fetch(url);
console.log(`${label}: ${response.status}`);
if (!response.ok) process.exit(1);
}
NODE

- name: Verify browser-facing API URL
run: |
docker compose exec -T web node -e "process.exit(process.env.VITE_API_BASE_URL === 'http://localhost:4000' ? 0 : 1)"

- name: Verify Docker services remain running
run: |
docker compose ps
test "$(docker compose ps --status running -q | wc -l)" -eq 2

- name: Docker logs
if: failure()
run: docker compose logs --no-color

- name: Clean up Docker stack
if: always()
run: docker compose down -v --remove-orphans
32 changes: 21 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,30 +29,40 @@ Preparing video for the web usually means juggling codec settings, fallback file

## Quick Start

### Docker Compose
### Local Node

Docker is the easiest way to run the app when Docker is available:
Local development needs Node.js 20 or newer and FFmpeg/FFprobe on PATH.

```powershell
git clone https://github.com/Artsen/web-video-optimizer.git
cd web-video-optimizer
docker compose up --build
npm ci
npm run dev
```

Open <http://localhost:5173>. The API listens on <http://localhost:4000>, and media is stored in the Docker `video_data` volume.
Open <http://localhost:5173>. The API listens on <http://localhost:4000> and must remain running while the web interface is used.

### Local Node
On Windows PowerShell, use `npm.cmd` if script execution policy blocks `npm.ps1`.

Local development needs Node.js 20 or newer and FFmpeg/FFprobe on PATH.
The two-console workflow is still supported:

```powershell
git clone https://github.com/Artsen/web-video-optimizer.git
cd web-video-optimizer
npm ci
npm run dev
npm run dev:api
```

On Windows PowerShell, use `npm.cmd` if script execution policy blocks `npm.ps1`.
```powershell
npm run dev:web
```

### Docker Compose

Docker is optional for ordinary local development. When Docker is available:

```powershell
docker compose up --build
```

Open <http://localhost:5173>. The API listens on <http://localhost:4000>, and media is stored in the Docker `video_data` volume. Docker validation for this project is performed by GitHub Actions on Ubuntu.

See [Getting Started](docs/getting-started.md) for FFmpeg setup, LAN access, yt-dlp imports, and optional whisper.cpp captions.

Expand Down
10 changes: 8 additions & 2 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,20 @@ RUN apt-get update \

WORKDIR /app

COPY package.json package-lock.json* ./
COPY package.json package-lock.json ./
COPY packages/contracts/package.json packages/contracts/package.json
COPY packages/video-core/package.json packages/video-core/package.json
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json

RUN npm install --workspace @local-video-optimizer/api
RUN npm ci

COPY packages/contracts packages/contracts
COPY packages/video-core packages/video-core
COPY apps/api apps/api

RUN npm run build:packages

WORKDIR /app/apps/api

EXPOSE 4000
Expand Down
54 changes: 54 additions & 0 deletions apps/api/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import {
CapabilitiesSchema,
HistorySnapshotSchema,
JobDtoSchema,
ReadinessDtoSchema,
StorageCleanupResultDtoSchema,
StorageStatusDtoSchema,
VideoRecordDtoSchema,
type Capabilities,
type HistorySnapshot,
type JobDto,
type OptimizationSettings,
type ReadinessDto,
type StorageCleanupResultDto,
type StorageStatusDto,
type VideoMetadata,
Expand Down Expand Up @@ -141,6 +143,28 @@ class FakeRuntime implements ApiRuntime {
};
}

async getReadiness(): Promise<ReadinessDto> {
return {
state: "ready",
checks: {
runtimeInitialized: { ok: true, state: "ready", message: "API runtime initialized" },
storageAvailable: { ok: true, state: "ready", message: "Managed storage available" },
manifestLoaded: { ok: true, state: "ready", message: "Manifest state loaded" },
ffmpegAvailable: { ok: true, state: "ready", message: "FFmpeg available" },
ffprobeAvailable: { ok: true, state: "ready", message: "FFprobe available" },
h264Encoding: { ok: true, state: "ready", message: "H.264/AAC fallback encoding available" },
modernWebmAv1: { ok: true, state: "ready", message: "AV1/WebM with Opus available" },
storagePressure: { ok: true, state: "ready", message: "Storage pressure normal" }
},
optional: {
ytDlpAvailable: { ok: false, state: "degraded", message: "yt-dlp available" },
whisperCppAvailable: { ok: false, state: "degraded", message: "whisper.cpp executable available" },
whisperModelConfigured: { ok: false, state: "degraded", message: "whisper.cpp model configured" }
},
storage: { pressure: "normal" }
};
}

async getStorageStatus(): Promise<StorageStatusDto> {
return storageStatus();
}
Expand Down Expand Up @@ -334,6 +358,36 @@ describe("public API response shapes", () => {
noPrivateFields(response.body);
});

it("returns redacted readiness matching the shared schema", async () => {
const { app } = makeApp();
const response = await request(app).get("/ready").expect(200);

ReadinessDtoSchema.parse(response.body);
expect(response.body.state).toBe("ready");
noPrivateFields(response.body);
expect(JSON.stringify(response.body)).not.toContain("D:/");
expect(JSON.stringify(response.body)).not.toContain("C:\\");
});

it("returns 503 when readiness reports required failures", async () => {
class NotReadyRuntime extends FakeRuntime {
override async getReadiness(): Promise<ReadinessDto> {
return {
...(await super.getReadiness()),
state: "not_ready",
checks: {
...(await super.getReadiness()).checks,
ffmpegAvailable: { ok: false, state: "not_ready", message: "FFmpeg available" }
}
};
}
}
const { app } = makeApp(new NotReadyRuntime());

const response = await request(app).get("/ready").expect(503);
ReadinessDtoSchema.parse(response.body);
});

it("returns history matching the shared schema without private fields", async () => {
const { app } = makeApp();
const response = await request(app).get("/api/history").expect(200);
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createHistoryRouter } from "./history-routes.js";
import { createImportRouter } from "./import-routes.js";
import { createJobRouter } from "./job-routes.js";
import { createPackageRouter } from "./package-routes.js";
import { createReadinessRouter } from "./readiness-routes.js";
import { createStorageRouter } from "./storage-routes.js";
import { createVideoRouter } from "./video-routes.js";

Expand All @@ -19,6 +20,7 @@ export type RouteDependencies = {

export function registerRoutes(app: Express, dependencies: RouteDependencies): void {
app.use(createHealthRouter());
app.use(createReadinessRouter(dependencies.runtime));
app.use(createCapabilityRouter(dependencies.runtime));
app.use(createStorageRouter(dependencies.runtime));
app.use(createHistoryRouter(dependencies.runtime));
Expand Down
15 changes: 15 additions & 0 deletions apps/api/src/routes/readiness-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Router } from "express";
import { asyncHandler } from "../middleware/async-handler.js";
import type { ApiRuntime } from "../runtime/api-runtime.js";

export function createReadinessRouter(runtime: ApiRuntime): Router {
const router = Router();
router.get(
"/ready",
asyncHandler(async (_req, res) => {
const readiness = await runtime.getReadiness();
res.status(readiness.state === "not_ready" ? 503 : 200).json(readiness);
})
);
return router;
}
2 changes: 2 additions & 0 deletions apps/api/src/runtime/api-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
VideoMetadata,
VideoRecordDto,
StorageCleanupResultDto,
ReadinessDto,
StorageStatusDto
} from "@local-video-optimizer/contracts";
import type { OpenedStoredFile, StorageArea } from "../storage/storage-boundary.js";
Expand All @@ -32,6 +33,7 @@ export type CaptionPayload = {
export interface ApiRuntime {
initialize(): Promise<void>;
getCapabilities(): Promise<Capabilities>;
getReadiness(): Promise<ReadinessDto>;
getStorageStatus(): Promise<StorageStatusDto>;
cleanupStorage(): Promise<StorageCleanupResultDto>;
getHistory(): HistorySnapshot;
Expand Down
15 changes: 15 additions & 0 deletions apps/api/src/runtime/production-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { JobExecutionService } from "../services/job-execution-service.js";
import { JobLifecycleService } from "../services/job-lifecycle-service.js";
import { JobService } from "../services/job-service.js";
import { PackageService } from "../services/package-service.js";
import { ReadinessService } from "../services/readiness-service.js";
import { ManifestStatePersistenceService } from "../services/state-persistence-service.js";
import { VideoService } from "../services/video-service.js";
import { StorageHousekeepingService } from "../storage/housekeeping-service.js";
Expand Down Expand Up @@ -139,6 +140,15 @@ export function createProductionRuntime(
storage
);
const capabilitiesService = new CapabilitiesService(ffmpegCapabilitiesAdapter, whisperAdapter, videoDownloader);
let runtimeInitialized = false;
let manifestLoaded = false;
const readinessService = new ReadinessService({
getCapabilities: () => capabilitiesService.getCapabilities(),
commandRunner,
storagePolicy,
isRuntimeInitialized: () => runtimeInitialized,
isManifestLoaded: () => manifestLoaded
});
const videoService = new VideoService(
videoRepository,
jobRepository,
Expand Down Expand Up @@ -236,11 +246,13 @@ export function createProductionRuntime(
processRegistry.clear();
await storage.initialize();
const recovery = await statePersistence.load();
manifestLoaded = true;
await videoService.mergeDuplicateVideos();
await cleanupService.pruneOrphanFiles();
if (dependencies.startHousekeeping !== false) housekeeping.start();
await statePersistence.save();
await statePersistence.flush();
runtimeInitialized = true;
if (
recovery.recoveredFromBackup ||
recovery.canceledInterruptedJobs > 0 ||
Expand All @@ -253,6 +265,9 @@ export function createProductionRuntime(
async getCapabilities() {
return capabilitiesService.getCapabilities();
},
async getReadiness() {
return readinessService.getReadiness();
},
async getStorageStatus() {
return storagePolicy.getStatus();
},
Expand Down
Loading
Loading