cang 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cang-0.1.0/PKG-INFO +63 -0
- cang-0.1.0/README.md +47 -0
- cang-0.1.0/pyproject.toml +53 -0
- cang-0.1.0/src/cang/__init__.py +4 -0
- cang-0.1.0/src/cang/adapters/__init__.py +9 -0
- cang-0.1.0/src/cang/adapters/base.py +23 -0
- cang-0.1.0/src/cang/adapters/dahua.py +105 -0
- cang-0.1.0/src/cang/cli.py +32 -0
- cang-0.1.0/src/cang/config.py +95 -0
- cang-0.1.0/src/cang/db.py +207 -0
- cang-0.1.0/src/cang/pipeline.py +179 -0
- cang-0.1.0/src/cang/web/__init__.py +1 -0
- cang-0.1.0/src/cang/web/app.py +38 -0
- cang-0.1.0/src/cang/web/auth.py +37 -0
- cang-0.1.0/src/cang/web/lifespan.py +153 -0
- cang-0.1.0/src/cang/web/routes/__init__.py +1 -0
- cang-0.1.0/src/cang/web/routes/clips.py +75 -0
- cang-0.1.0/src/cang/web/static/htmx.min.js +1 -0
- cang-0.1.0/src/cang/web/templates/_keep_btn.html +6 -0
- cang-0.1.0/src/cang/web/templates/base.html +38 -0
- cang-0.1.0/src/cang/web/templates/day.html +47 -0
- cang-0.1.0/src/cang/web/templates/days.html +16 -0
cang-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: cang
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight NVR: watches folders, transcodes to H.264/MP4, serves a FastAPI web UI.
|
|
5
|
+
Author: Marvin8
|
|
6
|
+
Author-email: Marvin8 <marvin8@tuta.io>
|
|
7
|
+
License: AGPL-3.0-or-later
|
|
8
|
+
Requires-Dist: aiosqlite~=0.22.1
|
|
9
|
+
Requires-Dist: fastapi[standard]~=0.136.3
|
|
10
|
+
Requires-Dist: uvicorn~=0.48.0
|
|
11
|
+
Requires-Dist: watchdog~=6.0.0
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
|
+
Project-URL: Issues, https://codeberg.org/MinimalNVR/cang/issues
|
|
14
|
+
Project-URL: Source, https://codeberg.org/MinimalNVR/cang
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Cang (藏) — Minimal Python Network Video Recorder
|
|
18
|
+
|
|
19
|
+
[](LICENCE)
|
|
20
|
+
[]()
|
|
21
|
+
[](https://ci.codeberg.org/MinimalNVR/cang)
|
|
22
|
+
|
|
23
|
+
**Cang** (藏, *cáng*, roughly "tsang" with a rising tone) — Chinese for "to store /
|
|
24
|
+
to archive". Secondary meaning: "hidden", quietly apt for security footage.
|
|
25
|
+
The name reflects the project's philosophy: cameras do all the clever work;
|
|
26
|
+
Cang just keeps what they send.
|
|
27
|
+
|
|
28
|
+
A lightweight NVR that does as little as possible: cameras handle motion detection
|
|
29
|
+
and object recognition themselves, deposit video clips and snapshot images into a
|
|
30
|
+
watched folder, and Cang transcodes, indexes, and serves them through a clean web UI
|
|
31
|
+
grouped by day and hour.
|
|
32
|
+
|
|
33
|
+
Inspired by [LazyNVR](https://codeberg.org/LazyNVR/lazynvr-sources), written in Python.
|
|
34
|
+
|
|
35
|
+
## What it does
|
|
36
|
+
|
|
37
|
+
- Watches `incoming/<camera>/` directories for clips deposited by cameras
|
|
38
|
+
- Transcodes to H.264/MP4 with `-movflags +faststart` (browser-seekable)
|
|
39
|
+
- Indexes clips in SQLite: camera, timestamp, duration, thumbnail
|
|
40
|
+
- Serves a FastAPI + Jinja2 web UI: day → hour view, click-to-play
|
|
41
|
+
- Keeps clips marked as important; auto-expires the rest after configurable days
|
|
42
|
+
- Runs as a single Podman container; storage is a bind-mounted volume
|
|
43
|
+
|
|
44
|
+
## What it does not do
|
|
45
|
+
|
|
46
|
+
- No RTSP / live-stream pulling
|
|
47
|
+
- No motion detection or object recognition (delegated to cameras)
|
|
48
|
+
- No multi-user roles or cloud sync
|
|
49
|
+
|
|
50
|
+
## Status
|
|
51
|
+
|
|
52
|
+
Idea phase. See [concept.md](concept.md) for the full design specification.
|
|
53
|
+
|
|
54
|
+
## Licence
|
|
55
|
+
|
|
56
|
+
Copyright (C) 2026 contributors
|
|
57
|
+
|
|
58
|
+
This program is free software: you can redistribute it and/or modify it under
|
|
59
|
+
the terms of the GNU Affero General Public License as published by the Free
|
|
60
|
+
Software Foundation, either version 3 of the License, or (at your option) any
|
|
61
|
+
later version.
|
|
62
|
+
|
|
63
|
+
See [LICENCE](LICENCE) for the full text.
|
cang-0.1.0/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Cang (藏) — Minimal Python Network Video Recorder
|
|
2
|
+
|
|
3
|
+
[](LICENCE)
|
|
4
|
+
[]()
|
|
5
|
+
[](https://ci.codeberg.org/MinimalNVR/cang)
|
|
6
|
+
|
|
7
|
+
**Cang** (藏, *cáng*, roughly "tsang" with a rising tone) — Chinese for "to store /
|
|
8
|
+
to archive". Secondary meaning: "hidden", quietly apt for security footage.
|
|
9
|
+
The name reflects the project's philosophy: cameras do all the clever work;
|
|
10
|
+
Cang just keeps what they send.
|
|
11
|
+
|
|
12
|
+
A lightweight NVR that does as little as possible: cameras handle motion detection
|
|
13
|
+
and object recognition themselves, deposit video clips and snapshot images into a
|
|
14
|
+
watched folder, and Cang transcodes, indexes, and serves them through a clean web UI
|
|
15
|
+
grouped by day and hour.
|
|
16
|
+
|
|
17
|
+
Inspired by [LazyNVR](https://codeberg.org/LazyNVR/lazynvr-sources), written in Python.
|
|
18
|
+
|
|
19
|
+
## What it does
|
|
20
|
+
|
|
21
|
+
- Watches `incoming/<camera>/` directories for clips deposited by cameras
|
|
22
|
+
- Transcodes to H.264/MP4 with `-movflags +faststart` (browser-seekable)
|
|
23
|
+
- Indexes clips in SQLite: camera, timestamp, duration, thumbnail
|
|
24
|
+
- Serves a FastAPI + Jinja2 web UI: day → hour view, click-to-play
|
|
25
|
+
- Keeps clips marked as important; auto-expires the rest after configurable days
|
|
26
|
+
- Runs as a single Podman container; storage is a bind-mounted volume
|
|
27
|
+
|
|
28
|
+
## What it does not do
|
|
29
|
+
|
|
30
|
+
- No RTSP / live-stream pulling
|
|
31
|
+
- No motion detection or object recognition (delegated to cameras)
|
|
32
|
+
- No multi-user roles or cloud sync
|
|
33
|
+
|
|
34
|
+
## Status
|
|
35
|
+
|
|
36
|
+
Idea phase. See [concept.md](concept.md) for the full design specification.
|
|
37
|
+
|
|
38
|
+
## Licence
|
|
39
|
+
|
|
40
|
+
Copyright (C) 2026 contributors
|
|
41
|
+
|
|
42
|
+
This program is free software: you can redistribute it and/or modify it under
|
|
43
|
+
the terms of the GNU Affero General Public License as published by the Free
|
|
44
|
+
Software Foundation, either version 3 of the License, or (at your option) any
|
|
45
|
+
later version.
|
|
46
|
+
|
|
47
|
+
See [LICENCE](LICENCE) for the full text.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.10.0,<0.12.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cang"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Marvin8", email = "marvin8@tuta.io" },
|
|
10
|
+
]
|
|
11
|
+
description = "Lightweight NVR: watches folders, transcodes to H.264/MP4, serves a FastAPI web UI."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
license = { text = "AGPL-3.0-or-later" }
|
|
14
|
+
requires-python = ">=3.12"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"aiosqlite~=0.22.1",
|
|
17
|
+
"fastapi[standard]~=0.136.3",
|
|
18
|
+
"uvicorn~=0.48.0",
|
|
19
|
+
"watchdog~=6.0.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"complexipy~=5.5.0",
|
|
25
|
+
"git-cliff~=2.13.1",
|
|
26
|
+
"httpx~=0.28.1",
|
|
27
|
+
"nox-uv~=0.8.0",
|
|
28
|
+
"prek~=0.4.3",
|
|
29
|
+
"ty~=0.0.40",
|
|
30
|
+
"ruff~=0.15.14",
|
|
31
|
+
"tryke~=0.0.29",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
cang = "cang.cli:main"
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Issues = "https://codeberg.org/MinimalNVR/cang/issues"
|
|
39
|
+
Source = "https://codeberg.org/MinimalNVR/cang"
|
|
40
|
+
|
|
41
|
+
[tool.uv]
|
|
42
|
+
constraint-dependencies = [
|
|
43
|
+
"starlette>=1.0.1",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
line-length = 88
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint]
|
|
50
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
51
|
+
|
|
52
|
+
[tool.tryke]
|
|
53
|
+
src = ["src"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
from cang.adapters.base import BaseAdapter
|
|
3
|
+
from cang.adapters.dahua import DahuaAdapter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def make_adapter(adapter_type: str) -> BaseAdapter:
|
|
7
|
+
if adapter_type == "dahua":
|
|
8
|
+
return DahuaAdapter()
|
|
9
|
+
raise ValueError(f"unknown adapter: {adapter_type!r}")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import date, time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class IncomingClip:
|
|
11
|
+
path: Path
|
|
12
|
+
date: date
|
|
13
|
+
channel: str
|
|
14
|
+
start: time
|
|
15
|
+
end: time
|
|
16
|
+
motion: bool
|
|
17
|
+
first_frame: bool
|
|
18
|
+
snapshots: list[Path] = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BaseAdapter(ABC):
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def scan(self, root: Path) -> Iterator[IncomingClip]: ...
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import re
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from datetime import date, time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cang.adapters.base import BaseAdapter, IncomingClip
|
|
8
|
+
|
|
9
|
+
_CLIP_RE = re.compile(r"^(\d{2})\.(\d{2})\.(\d{2})-(\d{2})\.(\d{2})\.(\d{2})(.*)")
|
|
10
|
+
_SS_RE = re.compile(r"^(\d{2})")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse_clip_stem(stem: str) -> tuple[time, time, bool, bool]:
|
|
14
|
+
m = _CLIP_RE.match(stem)
|
|
15
|
+
if not m:
|
|
16
|
+
raise ValueError(f"unrecognised clip stem: {stem!r}")
|
|
17
|
+
sh, sm, ss, eh, em, es, flags = m.groups()
|
|
18
|
+
return (
|
|
19
|
+
time(int(sh), int(sm), int(ss)),
|
|
20
|
+
time(int(eh), int(em), int(es)),
|
|
21
|
+
"[M]" in flags,
|
|
22
|
+
"[F]" in flags,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_snapshot_time(jpg: Path) -> time:
|
|
27
|
+
mm_dir = jpg.parent
|
|
28
|
+
hh_dir = mm_dir.parent
|
|
29
|
+
ss_m = _SS_RE.match(jpg.stem)
|
|
30
|
+
if not ss_m:
|
|
31
|
+
raise ValueError(f"unrecognised snapshot filename: {jpg.name!r}")
|
|
32
|
+
return time(int(hh_dir.name), int(mm_dir.name), int(ss_m.group(1)))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _minute_dirs_in_range(jpg_root: Path, start: time, end: time) -> Iterator[Path]:
|
|
36
|
+
if not jpg_root.is_dir():
|
|
37
|
+
return
|
|
38
|
+
for hh_dir in sorted(jpg_root.iterdir()):
|
|
39
|
+
if not hh_dir.is_dir():
|
|
40
|
+
continue
|
|
41
|
+
hh = int(hh_dir.name)
|
|
42
|
+
if not (start.hour <= hh <= end.hour):
|
|
43
|
+
continue
|
|
44
|
+
for mm_dir in sorted(hh_dir.iterdir()):
|
|
45
|
+
if not mm_dir.is_dir():
|
|
46
|
+
continue
|
|
47
|
+
mm = int(mm_dir.name)
|
|
48
|
+
if (start.hour, start.minute) <= (hh, mm) <= (end.hour, end.minute):
|
|
49
|
+
yield mm_dir
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _collect_snapshots(jpg_root: Path, start: time, end: time) -> list[Path]:
|
|
53
|
+
results: list[Path] = []
|
|
54
|
+
for mm_dir in _minute_dirs_in_range(jpg_root, start, end):
|
|
55
|
+
for jpg in sorted(mm_dir.glob("*.jpg")):
|
|
56
|
+
try:
|
|
57
|
+
if start <= _parse_snapshot_time(jpg) <= end:
|
|
58
|
+
results.append(jpg)
|
|
59
|
+
except ValueError:
|
|
60
|
+
continue
|
|
61
|
+
return results
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _date_dirs(root: Path) -> Iterator[tuple[date, Path]]:
|
|
65
|
+
for date_dir in sorted(root.iterdir()):
|
|
66
|
+
if not date_dir.is_dir():
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
yield date.fromisoformat(date_dir.name), date_dir
|
|
70
|
+
except ValueError:
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _clips_in_channel(clip_date: date, channel_dir: Path) -> Iterator[IncomingClip]:
|
|
75
|
+
dav_root = channel_dir / "dav"
|
|
76
|
+
jpg_root = channel_dir / "jpg"
|
|
77
|
+
if not dav_root.is_dir():
|
|
78
|
+
return
|
|
79
|
+
for hh_dir in sorted(dav_root.iterdir()):
|
|
80
|
+
if not hh_dir.is_dir():
|
|
81
|
+
continue
|
|
82
|
+
for dav in sorted(hh_dir.glob("*.dav")):
|
|
83
|
+
try:
|
|
84
|
+
start, end, motion, first_frame = _parse_clip_stem(dav.stem)
|
|
85
|
+
except ValueError:
|
|
86
|
+
continue
|
|
87
|
+
yield IncomingClip(
|
|
88
|
+
path=dav,
|
|
89
|
+
date=clip_date,
|
|
90
|
+
channel=channel_dir.name,
|
|
91
|
+
start=start,
|
|
92
|
+
end=end,
|
|
93
|
+
motion=motion,
|
|
94
|
+
first_frame=first_frame,
|
|
95
|
+
snapshots=_collect_snapshots(jpg_root, start, end),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class DahuaAdapter(BaseAdapter):
|
|
100
|
+
def scan(self, root: Path) -> Iterator[IncomingClip]:
|
|
101
|
+
for clip_date, date_dir in _date_dirs(root):
|
|
102
|
+
for channel_dir in sorted(date_dir.iterdir()):
|
|
103
|
+
if not channel_dir.is_dir():
|
|
104
|
+
continue
|
|
105
|
+
yield from _clips_in_channel(clip_date, channel_dir)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import argparse
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import uvicorn
|
|
7
|
+
|
|
8
|
+
from cang.config import load_config
|
|
9
|
+
from cang.db import open_db
|
|
10
|
+
from cang.pipeline import Pipeline
|
|
11
|
+
from cang.web.app import create_app
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_args() -> argparse.Namespace:
|
|
15
|
+
parser = argparse.ArgumentParser(description="Cang NVR — start the web UI")
|
|
16
|
+
parser.add_argument("config", type=Path, help="Path to cang.toml")
|
|
17
|
+
return parser.parse_args()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def _run(config_path: Path) -> None:
|
|
21
|
+
cfg = load_config(config_path)
|
|
22
|
+
async with open_db(cfg.server.output_dir / "cang.db") as db:
|
|
23
|
+
pipeline = Pipeline(cfg.server.output_dir)
|
|
24
|
+
app = create_app(cfg, db, pipeline)
|
|
25
|
+
server = uvicorn.Server(
|
|
26
|
+
uvicorn.Config(app, host=cfg.web.host, port=cfg.web.port, log_level="info")
|
|
27
|
+
)
|
|
28
|
+
await server.serve()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main() -> None:
|
|
32
|
+
asyncio.run(_run(_parse_args().config))
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import tomllib
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_KNOWN_ADAPTERS = {"dahua"}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ServerConfig:
|
|
11
|
+
output_dir: Path
|
|
12
|
+
scan_interval: int = 300
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class CameraConfig:
|
|
17
|
+
name: str
|
|
18
|
+
adapter: str
|
|
19
|
+
root: Path
|
|
20
|
+
delete_after_transcode: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class WebConfig:
|
|
25
|
+
host: str = "0.0.0.0"
|
|
26
|
+
port: int = 8080
|
|
27
|
+
username: str | None = None
|
|
28
|
+
password: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class AppConfig:
|
|
33
|
+
server: ServerConfig
|
|
34
|
+
cameras: list[CameraConfig]
|
|
35
|
+
web: WebConfig = field(default_factory=WebConfig)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_server(raw: dict) -> ServerConfig:
|
|
39
|
+
try:
|
|
40
|
+
output_dir = raw["output_dir"]
|
|
41
|
+
except KeyError as exc:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
"config: [server] is missing required key 'output_dir'"
|
|
44
|
+
) from exc
|
|
45
|
+
scan_interval = int(raw.get("scan_interval", 300))
|
|
46
|
+
return ServerConfig(output_dir=Path(output_dir), scan_interval=scan_interval)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_camera(raw: dict, index: int) -> CameraConfig:
|
|
50
|
+
for key in ("name", "adapter", "root"):
|
|
51
|
+
if key not in raw:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"config: [[camera]] entry {index} is missing required key '{key}'"
|
|
54
|
+
)
|
|
55
|
+
adapter = raw["adapter"]
|
|
56
|
+
if adapter not in _KNOWN_ADAPTERS:
|
|
57
|
+
known = ", ".join(sorted(_KNOWN_ADAPTERS))
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"config: [[camera]] entry {index} has unknown adapter {adapter!r}"
|
|
60
|
+
f" — must be one of: {known}"
|
|
61
|
+
)
|
|
62
|
+
return CameraConfig(
|
|
63
|
+
name=raw["name"],
|
|
64
|
+
adapter=adapter,
|
|
65
|
+
root=Path(raw["root"]),
|
|
66
|
+
delete_after_transcode=bool(raw.get("delete_after_transcode", False)),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_web(raw: dict) -> WebConfig:
|
|
71
|
+
username = raw.get("username")
|
|
72
|
+
password = raw.get("password")
|
|
73
|
+
if (username is None) != (password is None):
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"config: [web] requires both 'username' and 'password', or neither"
|
|
76
|
+
)
|
|
77
|
+
return WebConfig(
|
|
78
|
+
host=raw.get("host", "0.0.0.0"),
|
|
79
|
+
port=raw.get("port", 8080),
|
|
80
|
+
username=username,
|
|
81
|
+
password=password,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def load_config(path: Path) -> AppConfig:
|
|
86
|
+
with path.open("rb") as f:
|
|
87
|
+
data = tomllib.load(f)
|
|
88
|
+
try:
|
|
89
|
+
server_raw = data["server"]
|
|
90
|
+
except KeyError as exc:
|
|
91
|
+
raise ValueError("config: missing required section [server]") from exc
|
|
92
|
+
server = _parse_server(server_raw)
|
|
93
|
+
cameras = [_parse_camera(cam, i) for i, cam in enumerate(data.get("camera", []))]
|
|
94
|
+
web = _parse_web(data.get("web", {}))
|
|
95
|
+
return AppConfig(server=server, cameras=cameras, web=web)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
from collections.abc import AsyncIterator
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import aiosqlite
|
|
8
|
+
|
|
9
|
+
from cang.adapters.base import IncomingClip
|
|
10
|
+
|
|
11
|
+
_DDL_V1 = """
|
|
12
|
+
CREATE TABLE IF NOT EXISTS clips (
|
|
13
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
14
|
+
camera TEXT NOT NULL,
|
|
15
|
+
recorded_at TEXT NOT NULL,
|
|
16
|
+
recorded_end TEXT NOT NULL,
|
|
17
|
+
duration_s REAL NOT NULL,
|
|
18
|
+
filename TEXT NOT NULL,
|
|
19
|
+
thumbnail TEXT,
|
|
20
|
+
flags TEXT NOT NULL DEFAULT '',
|
|
21
|
+
keep INTEGER NOT NULL DEFAULT 0,
|
|
22
|
+
ingested_at TEXT NOT NULL
|
|
23
|
+
);
|
|
24
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_clips_unique
|
|
25
|
+
ON clips (camera, recorded_at, filename);
|
|
26
|
+
DELETE FROM schema_version;
|
|
27
|
+
INSERT INTO schema_version (version) VALUES (1);
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
_DDL_V2 = """
|
|
31
|
+
ALTER TABLE clips ADD COLUMN status TEXT NOT NULL DEFAULT 'done';
|
|
32
|
+
DELETE FROM schema_version;
|
|
33
|
+
INSERT INTO schema_version (version) VALUES (2);
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _apply_v1(db: aiosqlite.Connection) -> None:
|
|
38
|
+
await db.executescript(_DDL_V1)
|
|
39
|
+
await db.commit()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def _apply_v2(db: aiosqlite.Connection) -> None:
|
|
43
|
+
await db.executescript(_DDL_V2)
|
|
44
|
+
await db.commit()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _migrate(db: aiosqlite.Connection) -> None:
|
|
48
|
+
await db.execute(
|
|
49
|
+
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"
|
|
50
|
+
)
|
|
51
|
+
await db.commit()
|
|
52
|
+
async with db.execute("SELECT version FROM schema_version") as cur:
|
|
53
|
+
row = await cur.fetchone()
|
|
54
|
+
version = row[0] if row else 0
|
|
55
|
+
if version < 1:
|
|
56
|
+
await _apply_v1(db)
|
|
57
|
+
version = 1
|
|
58
|
+
if version < 2:
|
|
59
|
+
await _apply_v2(db)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@asynccontextmanager
|
|
63
|
+
async def open_db(path: Path | str) -> AsyncIterator[aiosqlite.Connection]:
|
|
64
|
+
async with aiosqlite.connect(path) as db:
|
|
65
|
+
db.row_factory = aiosqlite.Row
|
|
66
|
+
await _migrate(db)
|
|
67
|
+
yield db
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def insert_clip(
|
|
71
|
+
db: aiosqlite.Connection,
|
|
72
|
+
clip: IncomingClip,
|
|
73
|
+
camera: str,
|
|
74
|
+
filename: str,
|
|
75
|
+
) -> int:
|
|
76
|
+
recorded_at = datetime.combine(clip.date, clip.start).isoformat()
|
|
77
|
+
recorded_end = datetime.combine(clip.date, clip.end).isoformat()
|
|
78
|
+
duration_s = (
|
|
79
|
+
clip.end.hour * 3600
|
|
80
|
+
+ clip.end.minute * 60
|
|
81
|
+
+ clip.end.second
|
|
82
|
+
- clip.start.hour * 3600
|
|
83
|
+
- clip.start.minute * 60
|
|
84
|
+
- clip.start.second
|
|
85
|
+
)
|
|
86
|
+
flags = ("M" if clip.motion else "") + ("F" if clip.first_frame else "")
|
|
87
|
+
thumbnail = str(clip.snapshots[0]) if clip.snapshots else None
|
|
88
|
+
ingested_at = datetime.now(UTC).replace(tzinfo=None).isoformat()
|
|
89
|
+
cursor = await db.execute(
|
|
90
|
+
"""
|
|
91
|
+
INSERT OR IGNORE INTO clips
|
|
92
|
+
(camera, recorded_at, recorded_end, duration_s, filename,
|
|
93
|
+
thumbnail, flags, ingested_at, status)
|
|
94
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')
|
|
95
|
+
""",
|
|
96
|
+
(
|
|
97
|
+
camera,
|
|
98
|
+
recorded_at,
|
|
99
|
+
recorded_end,
|
|
100
|
+
duration_s,
|
|
101
|
+
filename,
|
|
102
|
+
thumbnail,
|
|
103
|
+
flags,
|
|
104
|
+
ingested_at,
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
await db.commit()
|
|
108
|
+
return cursor.lastrowid or 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def set_keep(db: aiosqlite.Connection, clip_id: int, keep: bool) -> None:
|
|
112
|
+
await db.execute("UPDATE clips SET keep = ? WHERE id = ?", (int(keep), clip_id))
|
|
113
|
+
await db.commit()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def set_clip_done(
|
|
117
|
+
db: aiosqlite.Connection,
|
|
118
|
+
clip_id: int,
|
|
119
|
+
thumbnail: str | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
if thumbnail is not None:
|
|
122
|
+
await db.execute(
|
|
123
|
+
"UPDATE clips SET status = 'done', thumbnail = ? WHERE id = ?",
|
|
124
|
+
(thumbnail, clip_id),
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
await db.execute("UPDATE clips SET status = 'done' WHERE id = ?", (clip_id,))
|
|
128
|
+
await db.commit()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def get_clip_by_camera_filename(
|
|
132
|
+
db: aiosqlite.Connection,
|
|
133
|
+
camera: str,
|
|
134
|
+
filename: str,
|
|
135
|
+
) -> aiosqlite.Row | None:
|
|
136
|
+
async with db.execute(
|
|
137
|
+
"SELECT * FROM clips WHERE camera = ? AND filename = ?",
|
|
138
|
+
(camera, filename),
|
|
139
|
+
) as cur:
|
|
140
|
+
return await cur.fetchone()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def count_pending_for_day(db: aiosqlite.Connection, day: str) -> int:
|
|
144
|
+
async with db.execute(
|
|
145
|
+
"SELECT COUNT(*) FROM clips WHERE DATE(recorded_at) = ? AND status = 'pending'",
|
|
146
|
+
(day,),
|
|
147
|
+
) as cur:
|
|
148
|
+
row = await cur.fetchone()
|
|
149
|
+
return row[0] if row else 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def delete_expired(db: aiosqlite.Connection, cutoff: datetime) -> int:
|
|
153
|
+
cursor = await db.execute(
|
|
154
|
+
"DELETE FROM clips WHERE keep = 0 AND recorded_at < ?",
|
|
155
|
+
(cutoff.isoformat(),),
|
|
156
|
+
)
|
|
157
|
+
await db.commit()
|
|
158
|
+
return cursor.rowcount
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def list_days(db: aiosqlite.Connection) -> list[str]:
|
|
162
|
+
async with db.execute(
|
|
163
|
+
"SELECT DISTINCT DATE(recorded_at) AS day FROM clips ORDER BY day DESC"
|
|
164
|
+
) as cur:
|
|
165
|
+
rows = await cur.fetchall()
|
|
166
|
+
return [row["day"] for row in rows]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def list_days_with_stats(db: aiosqlite.Connection) -> list[aiosqlite.Row]:
|
|
170
|
+
async with db.execute(
|
|
171
|
+
"""
|
|
172
|
+
SELECT
|
|
173
|
+
DATE(recorded_at) AS day,
|
|
174
|
+
COUNT(*) AS clip_count,
|
|
175
|
+
SUM(CASE WHEN flags LIKE '%M%' THEN 1 ELSE 0 END) AS motion_count,
|
|
176
|
+
ROUND(SUM(duration_s) / 3600.0, 2) AS total_hours
|
|
177
|
+
FROM clips
|
|
178
|
+
GROUP BY day
|
|
179
|
+
ORDER BY day DESC
|
|
180
|
+
"""
|
|
181
|
+
) as cur:
|
|
182
|
+
return list(await cur.fetchall())
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def list_clips_for_day(
|
|
186
|
+
db: aiosqlite.Connection,
|
|
187
|
+
day: str,
|
|
188
|
+
camera: str | None = None,
|
|
189
|
+
) -> list[aiosqlite.Row]:
|
|
190
|
+
if camera is None:
|
|
191
|
+
async with db.execute(
|
|
192
|
+
"SELECT * FROM clips WHERE DATE(recorded_at) = ? AND status = 'done'"
|
|
193
|
+
" ORDER BY recorded_at ASC",
|
|
194
|
+
(day,),
|
|
195
|
+
) as cur:
|
|
196
|
+
return list(await cur.fetchall())
|
|
197
|
+
async with db.execute(
|
|
198
|
+
"SELECT * FROM clips WHERE DATE(recorded_at) = ? AND camera = ?"
|
|
199
|
+
" AND status = 'done' ORDER BY recorded_at ASC",
|
|
200
|
+
(day, camera),
|
|
201
|
+
) as cur:
|
|
202
|
+
return list(await cur.fetchall())
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def get_clip(db: aiosqlite.Connection, clip_id: int) -> aiosqlite.Row | None:
|
|
206
|
+
async with db.execute("SELECT * FROM clips WHERE id = ?", (clip_id,)) as cur:
|
|
207
|
+
return await cur.fetchone()
|