diff --git a/README.md b/README.md index a47b282..f4e151d 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Cross-platform desktop and mobile viewer for OpenIPC IP cameras. Built with .NET 9 / 10 and Avalonia 12. [![build](https://github.com/OpenIPC/viewer/actions/workflows/build.yml/badge.svg)](https://github.com/OpenIPC/viewer/actions/workflows/build.yml) +[![latest release](https://img.shields.io/github/v/release/OpenIPC/viewer?sort=semver&label=release)](https://github.com/OpenIPC/viewer/releases/latest) +[![downloads](https://img.shields.io/github/downloads/OpenIPC/viewer/total?label=downloads)](https://github.com/OpenIPC/viewer/releases) > Status: **beta** — releases ship as standalone builds for Windows / Linux / > macOS / Android from the [releases page](https://github.com/OpenIPC/viewer/releases). diff --git a/docs/web-server.md b/docs/web-server.md index 806a614..da3c64a 100644 --- a/docs/web-server.md +++ b/docs/web-server.md @@ -12,6 +12,11 @@ LAN. No cloud, no extra services — one admin password and a port. * **Find cameras on the network** — ONVIF, mDNS and an opt-in subnet sweep, then add what you found without leaving the browser. * **PTZ** — pan/tilt/zoom and presets for cameras that support it. +* **Snapshots** — grab a still from any camera and download it. +* **Archive** — record a camera from the browser, then browse by calendar, play, + download, and export a marked fragment; seeking works because playback is a + ranged file response. Both heads write into the same folders and index, so a + clip recorded here shows up in the desktop app too. * **Users and permissions** — accounts with permission flags and, if you like, a per-user subset of cameras. * **H.264 over WebSocket** (fMP4 + MSE); H.265 is transcoded on the fly. One @@ -66,6 +71,7 @@ there is no TLS at this layer; for anything beyond your trusted LAN, put it | Env var | Meaning | |-------------------------------|--------------------------------------------------| | `OPENIPC_WEB_ADMIN_PASSWORD` | Built-in admin password. Unset → random, logged on start. | +| `OPENIPC_WEB_SEGMENT_SECONDS` | Length of one recording segment. Default 600 (10 min). | --- @@ -104,7 +110,7 @@ Out of the box there is one account: the built-in administrator from |----------------|---------------------------------------------------------------| | `watch live` | see the camera list and live video | | `PTZ` | move the camera and manage its presets | -| `export` | reserved for archive export | +| `export` | cut and download a fragment of a recording | | `manage` | everything that changes the installation — cameras, groups, layouts, discovery, backups, sessions, users | and, optionally, a **subset of cameras**: leave *All cameras* on for full access, diff --git a/docs/web-server.ru.md b/docs/web-server.ru.md index a4ec41f..d85a2b8 100644 --- a/docs/web-server.ru.md +++ b/docs/web-server.ru.md @@ -15,6 +15,11 @@ OpenIPC-камерам по локальной сети. Без облака и * **Поиск камер в сети** — ONVIF, mDNS и опциональный обход подсети, с добавлением найденного прямо из браузера. * **PTZ** — поворот, наклон, зум и пресеты для камер, которые это умеют. +* **Снимки** — кадр с любой камеры, с возможностью скачать. +* **Архив** — запись камеры прямо из браузера, затем календарь, просмотр, скачивание + и экспорт отмеченного фрагмента; перемотка работает, потому что файл отдаётся по + диапазонам. Обе морды пишут в одни и те же папки и индекс, так что записанное + здесь видно и в десктопе. * **Пользователи и права** — учётные записи с флагами прав и, при желании, подмножеством камер на каждого. * **H.264 по WebSocket** (fMP4 + MSE); H.265 транскодируется на лету. Одна @@ -69,6 +74,7 @@ OpenIPC-камерам по локальной сети. Без облака и | Переменная окружения | Значение | |-------------------------------|------------------------------------------------------| | `OPENIPC_WEB_ADMIN_PASSWORD` | Пароль встроенного админа. Не задан → случайный, печатается в лог. | +| `OPENIPC_WEB_SEGMENT_SECONDS` | Длина одного сегмента записи. По умолчанию 600 (10 мин). | --- @@ -108,7 +114,7 @@ OpenIPC-камерам по локальной сети. Без облака и |------------------|-------------------------------------------------------------| | `смотреть эфир` | видеть список камер и живое видео | | `PTZ` | двигать камеру и управлять её пресетами | -| `экспорт` | зарезервировано под экспорт архива | +| `экспорт` | вырезать и скачать фрагмент записи | | `управление` | всё, что меняет установку: камеры, группы, раскладки, поиск, бэкапы, сессии, пользователи | и, при желании, **подмножество камер**: оставьте «Все камеры» для полного доступа diff --git a/src/OpenIPC.Viewer.Web.Client/src/App.tsx b/src/OpenIPC.Viewer.Web.Client/src/App.tsx index 3aeedaa..eb37d79 100644 --- a/src/OpenIPC.Viewer.Web.Client/src/App.tsx +++ b/src/OpenIPC.Viewer.Web.Client/src/App.tsx @@ -8,6 +8,9 @@ import { Camera } from './pages/Camera' import { Grid } from './pages/Grid' import { Discovery } from './pages/Discovery' import { Users } from './pages/Users' +import { RequireManage, Settings, SettingsIndex } from './pages/Settings' +import { Recordings } from './pages/Recordings' +import { Snapshots } from './pages/Snapshots' import { Groups } from './pages/Groups' import { System } from './pages/System' @@ -31,10 +34,34 @@ export function App() { } /> } /> } /> - } /> - } /> - } /> - } /> + } /> + } /> + + + + } + /> + {/* Configuration lives under /settings; the old flat paths still + resolve so existing links and bookmarks don't break. */} + }> + } /> + } /> + + + + } + /> + } /> + + } /> + } /> + } /> } /> diff --git a/src/OpenIPC.Viewer.Web.Client/src/api.ts b/src/OpenIPC.Viewer.Web.Client/src/api.ts index 4fa6127..4eb88be 100644 --- a/src/OpenIPC.Viewer.Web.Client/src/api.ts +++ b/src/OpenIPC.Viewer.Web.Client/src/api.ts @@ -42,10 +42,71 @@ export type WebUserDto = { cameras: string[] | null } +// One recorded file. `playable` is false for H.265, which browsers won't decode +// — the UI then offers a download instead of a dead player. +export type RecordingDto = { + id: string + cameraId: string + cameraName: string | null + fileName: string + startedAt: string + endedAt: string | null + durationSeconds: number | null + sizeBytes: number + codec: string | null + hasMotion: boolean + playable: boolean +} + +// One dot on the archive calendar. Deliberately minimal: the browser groups +// these into days in its own time zone. +// A page of the archive: the rows plus where they sit in the whole set. +export type RecordingPageDto = { + total: number + offset: number + limit: number + items: RecordingDto[] +} + +export type CalendarPointDto = { startedAt: string; sizeBytes: number } + export type GroupDto = { id: number; name: string; sortOrder: number } export type PtzPresetDto = { token: string; name: string } +// A snapshot kept in the shared library (same rows the desktop browser reads). +export type SnapshotDto = { + id: string + cameraId: string + cameraName: string | null + takenAt: string + width: number + height: number + hasThumb: boolean + source: string +} + +// A Majestic config.json, flattened by the server into whatever scalar knobs the +// live config actually exposes — the schema drifts between firmware builds, so +// nothing here is a fixed field list. +export type MajesticFieldDto = { + path: string + section: string + key: string + kind: 'string' | 'bool' | 'int' | 'number' | 'enum' + value: string + options: string[] | null + // Changing this one restarts the video pipeline (the tile will blink). + restart: boolean +} + +export type MajesticDto = { + info: { model: string | null; firmware: string | null; chip: string | null; uptime: string | null } | null + nightMode: 'unknown' | 'day' | 'night' | 'auto' + sections: { name: string; fields: MajesticFieldDto[] }[] + rawJson: string +} + export type LayoutDto = { id: number name: string @@ -126,6 +187,14 @@ export class ApiError extends Error { } } +// Drops empty values so the URL only carries the filters that are actually set. +function query(params: Record): string { + const pairs = Object.entries(params) + .filter(([, v]) => v !== undefined && v !== '') + .map(([k, v]) => [k, String(v)] as [string, string]) + return pairs.length ? '?' + new URLSearchParams(pairs).toString() : '' +} + async function req(method: string, path: string, body?: unknown): Promise { const res = await fetch(path, { method, @@ -174,6 +243,56 @@ export const api = { req('POST', '/api/v1/discovery/probe', body), addDiscovered: (body: DiscoveryAdd) => req('POST', '/api/v1/discovery/add', body), + // The recorded archive. Playback is a ranged file response, so the URL goes + // straight into a