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 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: AGPL-3.0-or-later](https://img.shields.io/badge/licence-AGPL--3.0--or--later-blue)](LICENCE)
20
+ [![Status: Idea phase](https://img.shields.io/badge/status-idea%20phase-orange)]()
21
+ [![Woodpecker CI](https://ci.codeberg.org/api/badges/MinimalNVR/cang/status.svg)](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: AGPL-3.0-or-later](https://img.shields.io/badge/licence-AGPL--3.0--or--later-blue)](LICENCE)
4
+ [![Status: Idea phase](https://img.shields.io/badge/status-idea%20phase-orange)]()
5
+ [![Woodpecker CI](https://ci.codeberg.org/api/badges/MinimalNVR/cang/status.svg)](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,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ from importlib.metadata import version
3
+
4
+ __version__ = version("cang")
@@ -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()