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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
8 changes: 7 additions & 1 deletion docs/web-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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). |

---

Expand Down Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion docs/web-server.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ OpenIPC-камерам по локальной сети. Без облака и
* **Поиск камер в сети** — ONVIF, mDNS и опциональный обход подсети, с добавлением
найденного прямо из браузера.
* **PTZ** — поворот, наклон, зум и пресеты для камер, которые это умеют.
* **Снимки** — кадр с любой камеры, с возможностью скачать.
* **Архив** — запись камеры прямо из браузера, затем календарь, просмотр, скачивание
и экспорт отмеченного фрагмента; перемотка работает, потому что файл отдаётся по
диапазонам. Обе морды пишут в одни и те же папки и индекс, так что записанное
здесь видно и в десктопе.
* **Пользователи и права** — учётные записи с флагами прав и, при желании,
подмножеством камер на каждого.
* **H.264 по WebSocket** (fMP4 + MSE); H.265 транскодируется на лету. Одна
Expand Down Expand Up @@ -69,6 +74,7 @@ OpenIPC-камерам по локальной сети. Без облака и
| Переменная окружения | Значение |
|-------------------------------|------------------------------------------------------|
| `OPENIPC_WEB_ADMIN_PASSWORD` | Пароль встроенного админа. Не задан → случайный, печатается в лог. |
| `OPENIPC_WEB_SEGMENT_SECONDS` | Длина одного сегмента записи. По умолчанию 600 (10 мин). |

---

Expand Down Expand Up @@ -108,7 +114,7 @@ OpenIPC-камерам по локальной сети. Без облака и
|------------------|-------------------------------------------------------------|
| `смотреть эфир` | видеть список камер и живое видео |
| `PTZ` | двигать камеру и управлять её пресетами |
| `экспорт` | зарезервировано под экспорт архива |
| `экспорт` | вырезать и скачать фрагмент записи |
| `управление` | всё, что меняет установку: камеры, группы, раскладки, поиск, бэкапы, сессии, пользователи |

и, при желании, **подмножество камер**: оставьте «Все камеры» для полного доступа
Expand Down
35 changes: 31 additions & 4 deletions src/OpenIPC.Viewer.Web.Client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -31,10 +34,34 @@ export function App() {
<Route path="/cameras" element={<Cameras />} />
<Route path="/camera/:id" element={<Camera />} />
<Route path="/grid" element={<Grid />} />
<Route path="/discovery" element={<Discovery />} />
<Route path="/groups" element={<Groups />} />
<Route path="/system" element={<System />} />
<Route path="/users" element={<Users />} />
<Route path="/recordings" element={<Recordings />} />
<Route path="/snapshots" element={<Snapshots />} />
<Route
path="/discovery"
element={
<RequireManage>
<Discovery />
</RequireManage>
}
/>
{/* Configuration lives under /settings; the old flat paths still
resolve so existing links and bookmarks don't break. */}
<Route path="/settings" element={<Settings />}>
<Route index element={<SettingsIndex />} />
<Route path="groups" element={<Groups />} />
<Route
path="users"
element={
<RequireManage>
<Users />
</RequireManage>
}
/>
<Route path="system" element={<System />} />
</Route>
<Route path="/groups" element={<Navigate to="/settings/groups" replace />} />
<Route path="/users" element={<Navigate to="/settings/users" replace />} />
<Route path="/system" element={<Navigate to="/settings/system" replace />} />
<Route path="*" element={<Navigate to="/cameras" replace />} />
</Route>
</Routes>
Expand Down
139 changes: 139 additions & 0 deletions src/OpenIPC.Viewer.Web.Client/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, string | number | undefined>): 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<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method,
Expand Down Expand Up @@ -174,6 +243,56 @@ export const api = {
req<CameraDraftDto>('POST', '/api/v1/discovery/probe', body),
addDiscovered: (body: DiscoveryAdd) => req<CameraDto>('POST', '/api/v1/discovery/add', body),

// The recorded archive. Playback is a ranged file response, so the URL goes
// straight into a <video> and the browser does its own seeking.
recordings: (filter: { cameraId?: string; from?: string; to?: string; offset?: number; limit?: number } = {}) =>
req<RecordingPageDto>(
'GET',
'/api/v1/recordings' +
query({
...filter,
offset: filter.offset ? String(filter.offset) : undefined,
limit: filter.limit ? String(filter.limit) : undefined,
}),
),
// Cut of a recording, as a download URL (start/end are seconds into the file).
recordingExportUrl: (id: string, start: number, end: number, precise: boolean) =>
`/api/v1/recordings/${id}/export?start=${start.toFixed(2)}&end=${end.toFixed(2)}` +
(precise ? '&precise=true' : ''),
recordingCalendar: (filter: { cameraId?: string; from?: string; to?: string } = {}) =>
req<CalendarPointDto[]>('GET', '/api/v1/recordings/calendar' + query(filter)),
recordingStreamUrl: (id: string, download = false) =>
`/api/v1/recordings/${id}/stream` + (download ? '?download=true' : ''),
deleteRecording: (id: string) => req<void>('DELETE', `/api/v1/recordings/${id}`),
// Camera ids currently being recorded by this server.
activeRecordings: () => req<string[]>('GET', '/api/v1/recordings/active'),
startRecording: (cameraId: string) =>
req<{ id: string; startedAt: string }>('POST', `/api/v1/cameras/${cameraId}/recording/start`),
stopRecording: (cameraId: string) =>
req<{ id: string; sizeBytes: number }>('POST', `/api/v1/cameras/${cameraId}/recording/stop`),

// A fresh still, straight from the camera (Majestic /image.jpg when available,
// otherwise one ffmpeg pull). Used as an <img> src and as a download target,
// so it's a URL rather than a fetch wrapper.
snapshotUrl: (id: string) => `/api/v1/cameras/${id}/snapshot`,

// Keep a still: captured the same way as the preview above, but written into
// the shared library so the desktop gallery sees it too.
saveSnapshot: (cameraId: string) => req<SnapshotDto>('POST', `/api/v1/cameras/${cameraId}/snapshots`),
snapshots: (q: { cameraId?: string; offset?: number; limit?: number } = {}) => {
const p = new URLSearchParams()
if (q.cameraId) p.set('cameraId', q.cameraId)
if (q.offset) p.set('offset', String(q.offset))
if (q.limit) p.set('limit', String(q.limit))
const qs = p.toString()
return req<{ total: number; offset: number; limit: number; items: SnapshotDto[] }>(
'GET', '/api/v1/snapshots' + (qs ? '?' + qs : ''))
},
// A URL rather than a fetch: these are <img> sources and download targets.
snapshotImageUrl: (id: string, thumb = false, download = false) =>
`/api/v1/snapshots/${id}/image` + (thumb ? '?thumb=true' : download ? '?download=true' : ''),
deleteSnapshot: (id: string) => req<void>('DELETE', `/api/v1/snapshots/${id}`),

// PTZ. Movement is stateless on the server: each move carries a self-stop
// timeout, so the caller must repeat it while a direction is held (see PtzPad)
// and send stop on release. A camera that never gets the stop halts on its own.
Expand All @@ -188,6 +307,26 @@ export const api = {
ptzDeletePreset: (id: string, token: string) =>
req<void>('DELETE', `/api/v1/cameras/${id}/ptz/presets/${encodeURIComponent(token)}`),

// Camera settings (Majestic config.json). Manage-only on the server, reads
// included: the config carries the camera's own logins.
majestic: (id: string) => req<MajesticDto>('GET', `/api/v1/cameras/${id}/majestic`),
// Only changed fields travel; the server re-reads the camera's config and
// applies them onto it, so a stale tab can't revert someone else's edit.
majesticApply: (id: string, edits: { path: string; value: string }[]) =>
req<{ applied: number; restart: boolean }>('POST', `/api/v1/cameras/${id}/majestic/config`, { edits }),
majesticRaw: (id: string, json: string) =>
req<void>('POST', `/api/v1/cameras/${id}/majestic/raw`, { json }),
majesticNight: (id: string, mode: 'day' | 'night' | 'auto') =>
req<void>('POST', `/api/v1/cameras/${id}/majestic/night`, { mode }),
majesticMetrics: (id: string) =>
req<{ name: string; value: number }[]>('GET', `/api/v1/cameras/${id}/majestic/metrics`),

// Can this camera be talked to, and is someone already doing it? A real RTSP
// probe, so it costs a round-trip to the camera — the UI asks on press, not
// on page load.
talkProbe: (id: string) =>
req<{ supported: boolean | null; busy: boolean }>('GET', `/api/v1/cameras/${id}/talk`),

system: () =>
req<{ version: string; cameras: number; groups: number; sessions: number; streams: number }>(
'GET',
Expand Down
2 changes: 1 addition & 1 deletion src/OpenIPC.Viewer.Web.Client/src/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type Auth = {
logout: () => Promise<void>
}

export type Permission = 'ViewLive' | 'ViewArchive' | 'Ptz' | 'Export' | 'Manage'
export type Permission = 'ViewLive' | 'ViewArchive' | 'Ptz' | 'Export' | 'Manage' | 'Talk'

const Ctx = createContext<Auth | null>(null)

Expand Down
Loading
Loading