cang 0.1.0__py3-none-any.whl
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/__init__.py +4 -0
- cang/adapters/__init__.py +9 -0
- cang/adapters/base.py +23 -0
- cang/adapters/dahua.py +105 -0
- cang/cli.py +32 -0
- cang/config.py +95 -0
- cang/db.py +207 -0
- cang/pipeline.py +179 -0
- cang/web/__init__.py +1 -0
- cang/web/app.py +38 -0
- cang/web/auth.py +37 -0
- cang/web/lifespan.py +153 -0
- cang/web/routes/__init__.py +1 -0
- cang/web/routes/clips.py +75 -0
- cang/web/static/htmx.min.js +1 -0
- cang/web/templates/_keep_btn.html +6 -0
- cang/web/templates/base.html +38 -0
- cang/web/templates/day.html +47 -0
- cang/web/templates/days.html +16 -0
- cang-0.1.0.dist-info/METADATA +63 -0
- cang-0.1.0.dist-info/RECORD +23 -0
- cang-0.1.0.dist-info/WHEEL +4 -0
- cang-0.1.0.dist-info/entry_points.txt +3 -0
cang/__init__.py
ADDED
|
@@ -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}")
|
cang/adapters/base.py
ADDED
|
@@ -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]: ...
|
cang/adapters/dahua.py
ADDED
|
@@ -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)
|
cang/cli.py
ADDED
|
@@ -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))
|
cang/config.py
ADDED
|
@@ -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)
|
cang/db.py
ADDED
|
@@ -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()
|
cang/pipeline.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import asyncio
|
|
3
|
+
from collections.abc import Callable, Coroutine
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
10
|
+
from watchdog.observers import Observer
|
|
11
|
+
from watchdog.observers.api import BaseObserver
|
|
12
|
+
|
|
13
|
+
from cang.adapters.base import IncomingClip
|
|
14
|
+
|
|
15
|
+
_WATCHED_EXTENSIONS = frozenset({".dav", ".mp4"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class TranscodeJob:
|
|
20
|
+
clip: IncomingClip
|
|
21
|
+
output_path: Path
|
|
22
|
+
log_path: Path
|
|
23
|
+
delete_source: bool = False
|
|
24
|
+
on_done: Callable[[], Coroutine[Any, Any, None]] | None = field(
|
|
25
|
+
default=None, compare=False
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_cmd(job: TranscodeJob) -> list[str]:
|
|
30
|
+
src = str(job.clip.path)
|
|
31
|
+
dst = str(job.output_path)
|
|
32
|
+
if job.clip.path.suffix.lower() == ".mp4":
|
|
33
|
+
return [
|
|
34
|
+
"ffmpeg",
|
|
35
|
+
"-y",
|
|
36
|
+
"-i",
|
|
37
|
+
src,
|
|
38
|
+
"-c:v",
|
|
39
|
+
"copy",
|
|
40
|
+
"-c:a",
|
|
41
|
+
"copy",
|
|
42
|
+
"-movflags",
|
|
43
|
+
"+faststart",
|
|
44
|
+
dst,
|
|
45
|
+
]
|
|
46
|
+
return [
|
|
47
|
+
"ffmpeg",
|
|
48
|
+
"-y",
|
|
49
|
+
"-i",
|
|
50
|
+
src,
|
|
51
|
+
"-c:v",
|
|
52
|
+
"libx264",
|
|
53
|
+
"-preset",
|
|
54
|
+
"fast",
|
|
55
|
+
"-crf",
|
|
56
|
+
"23",
|
|
57
|
+
"-c:a",
|
|
58
|
+
"aac",
|
|
59
|
+
"-movflags",
|
|
60
|
+
"+faststart",
|
|
61
|
+
dst,
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _delete_source(job: TranscodeJob) -> None:
|
|
66
|
+
job.clip.path.unlink(missing_ok=True)
|
|
67
|
+
for snap in job.clip.snapshots:
|
|
68
|
+
snap.unlink(missing_ok=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def transcode(job: TranscodeJob) -> None:
|
|
72
|
+
job.output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
cmd = _build_cmd(job)
|
|
74
|
+
ts = datetime.now(UTC).isoformat(timespec="seconds")
|
|
75
|
+
with job.log_path.open("ab") as log_fh:
|
|
76
|
+
log_fh.write(f"\n--- {ts} {job.clip.path} -> {job.output_path} ---\n".encode())
|
|
77
|
+
proc = await asyncio.create_subprocess_exec(
|
|
78
|
+
*cmd,
|
|
79
|
+
stdout=log_fh,
|
|
80
|
+
stderr=log_fh,
|
|
81
|
+
)
|
|
82
|
+
try:
|
|
83
|
+
await proc.wait()
|
|
84
|
+
except asyncio.CancelledError:
|
|
85
|
+
proc.kill()
|
|
86
|
+
await proc.wait()
|
|
87
|
+
raise
|
|
88
|
+
if proc.returncode != 0:
|
|
89
|
+
raise RuntimeError(f"ffmpeg exited {proc.returncode}: see {job.log_path}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _WatchHandler(FileSystemEventHandler):
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
callback: Callable[[Path], Coroutine[Any, Any, None]],
|
|
96
|
+
loop: asyncio.AbstractEventLoop,
|
|
97
|
+
) -> None:
|
|
98
|
+
self._callback = callback
|
|
99
|
+
self._loop = loop
|
|
100
|
+
|
|
101
|
+
def on_closed(self, event: FileSystemEvent) -> None:
|
|
102
|
+
if event.is_directory:
|
|
103
|
+
return
|
|
104
|
+
path = Path(str(event.src_path))
|
|
105
|
+
if path.suffix.lower() in _WATCHED_EXTENSIONS:
|
|
106
|
+
asyncio.run_coroutine_threadsafe(self._callback(path), self._loop)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class FileWatcher:
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
watch_root: Path,
|
|
113
|
+
callback: Callable[[Path], Coroutine[Any, Any, None]],
|
|
114
|
+
) -> None:
|
|
115
|
+
self._watch_root = watch_root
|
|
116
|
+
self._callback = callback
|
|
117
|
+
self._observer: BaseObserver | None = None
|
|
118
|
+
|
|
119
|
+
async def start(self) -> None:
|
|
120
|
+
loop = asyncio.get_running_loop()
|
|
121
|
+
handler = _WatchHandler(self._callback, loop)
|
|
122
|
+
self._observer = Observer()
|
|
123
|
+
self._observer.schedule(handler, str(self._watch_root), recursive=True)
|
|
124
|
+
self._observer.start()
|
|
125
|
+
|
|
126
|
+
def stop(self) -> None:
|
|
127
|
+
if self._observer is not None:
|
|
128
|
+
self._observer.stop()
|
|
129
|
+
self._observer.join()
|
|
130
|
+
self._observer = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Pipeline:
|
|
134
|
+
def __init__(self, output_dir: Path) -> None:
|
|
135
|
+
self._output_dir = output_dir
|
|
136
|
+
self._queue: asyncio.Queue[TranscodeJob] = asyncio.Queue()
|
|
137
|
+
self._task: asyncio.Task[None] | None = None
|
|
138
|
+
self._watchers: list[FileWatcher] = []
|
|
139
|
+
|
|
140
|
+
async def enqueue(self, job: TranscodeJob) -> None:
|
|
141
|
+
await self._queue.put(job)
|
|
142
|
+
|
|
143
|
+
async def start(self) -> None:
|
|
144
|
+
self._task = asyncio.get_running_loop().create_task(self._worker())
|
|
145
|
+
|
|
146
|
+
async def watch(
|
|
147
|
+
self,
|
|
148
|
+
watch_root: Path,
|
|
149
|
+
callback: Callable[[Path], Coroutine[Any, Any, None]],
|
|
150
|
+
) -> None:
|
|
151
|
+
watcher = FileWatcher(watch_root, callback)
|
|
152
|
+
await watcher.start()
|
|
153
|
+
self._watchers.append(watcher)
|
|
154
|
+
|
|
155
|
+
async def stop(self) -> None:
|
|
156
|
+
for watcher in self._watchers:
|
|
157
|
+
watcher.stop()
|
|
158
|
+
self._watchers.clear()
|
|
159
|
+
if self._task is not None:
|
|
160
|
+
self._task.cancel()
|
|
161
|
+
try:
|
|
162
|
+
await self._task
|
|
163
|
+
except asyncio.CancelledError:
|
|
164
|
+
pass
|
|
165
|
+
self._task = None
|
|
166
|
+
|
|
167
|
+
async def _worker(self) -> None:
|
|
168
|
+
while True:
|
|
169
|
+
job = await self._queue.get()
|
|
170
|
+
try:
|
|
171
|
+
await transcode(job)
|
|
172
|
+
if job.on_done is not None:
|
|
173
|
+
await job.on_done()
|
|
174
|
+
if job.delete_source:
|
|
175
|
+
_delete_source(job)
|
|
176
|
+
except RuntimeError:
|
|
177
|
+
pass # ffmpeg failure already logged to ffmpeg.log; continue
|
|
178
|
+
finally:
|
|
179
|
+
self._queue.task_done()
|
cang/web/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
cang/web/app.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import aiosqlite
|
|
5
|
+
from fastapi import Depends, FastAPI
|
|
6
|
+
from fastapi.staticfiles import StaticFiles
|
|
7
|
+
from fastapi.templating import Jinja2Templates
|
|
8
|
+
|
|
9
|
+
from cang.config import AppConfig
|
|
10
|
+
from cang.pipeline import Pipeline
|
|
11
|
+
from cang.web.auth import require_auth
|
|
12
|
+
from cang.web.lifespan import lifespan
|
|
13
|
+
from cang.web.routes.clips import router as clips_router
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_app(
|
|
17
|
+
cfg: AppConfig,
|
|
18
|
+
db: aiosqlite.Connection,
|
|
19
|
+
pipeline: Pipeline,
|
|
20
|
+
) -> FastAPI:
|
|
21
|
+
app = FastAPI(lifespan=lifespan)
|
|
22
|
+
app.state.config = cfg
|
|
23
|
+
app.state.db = db
|
|
24
|
+
app.state.pipeline = pipeline
|
|
25
|
+
app.state.templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
|
|
26
|
+
app.include_router(clips_router, dependencies=[Depends(require_auth(cfg.web))])
|
|
27
|
+
app.mount(
|
|
28
|
+
"/static",
|
|
29
|
+
StaticFiles(directory=Path(__file__).parent / "static"),
|
|
30
|
+
name="static",
|
|
31
|
+
)
|
|
32
|
+
if cfg.server.output_dir.exists():
|
|
33
|
+
app.mount(
|
|
34
|
+
"/clips/video",
|
|
35
|
+
StaticFiles(directory=cfg.server.output_dir),
|
|
36
|
+
name="video",
|
|
37
|
+
)
|
|
38
|
+
return app
|
cang/web/auth.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import secrets
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, HTTPException
|
|
6
|
+
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
|
7
|
+
|
|
8
|
+
from cang.config import WebConfig
|
|
9
|
+
|
|
10
|
+
_security = HTTPBasic(auto_error=False)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def require_auth(cfg: WebConfig) -> Callable:
|
|
14
|
+
if cfg.username is None:
|
|
15
|
+
return lambda: None
|
|
16
|
+
|
|
17
|
+
username = cfg.username
|
|
18
|
+
password = cfg.password or ""
|
|
19
|
+
|
|
20
|
+
def _check(
|
|
21
|
+
credentials: HTTPBasicCredentials | None = Depends(_security), # noqa: B008
|
|
22
|
+
) -> None:
|
|
23
|
+
if credentials is None:
|
|
24
|
+
raise HTTPException(
|
|
25
|
+
status_code=401,
|
|
26
|
+
headers={"WWW-Authenticate": "Basic"},
|
|
27
|
+
)
|
|
28
|
+
ok = secrets.compare_digest(credentials.username, username) and (
|
|
29
|
+
secrets.compare_digest(credentials.password, password)
|
|
30
|
+
)
|
|
31
|
+
if not ok:
|
|
32
|
+
raise HTTPException(
|
|
33
|
+
status_code=401,
|
|
34
|
+
headers={"WWW-Authenticate": "Basic"},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
return _check
|
cang/web/lifespan.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import asyncio
|
|
3
|
+
import shutil
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import aiosqlite
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
|
|
11
|
+
from cang.adapters import make_adapter
|
|
12
|
+
from cang.adapters.base import IncomingClip
|
|
13
|
+
from cang.config import AppConfig, CameraConfig
|
|
14
|
+
from cang.db import (
|
|
15
|
+
get_clip_by_camera_filename,
|
|
16
|
+
insert_clip,
|
|
17
|
+
set_clip_done,
|
|
18
|
+
)
|
|
19
|
+
from cang.pipeline import Pipeline, TranscodeJob
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _safe_filename(clip: IncomingClip) -> str:
|
|
23
|
+
return (
|
|
24
|
+
f"{clip.date.isoformat()}"
|
|
25
|
+
f"-{clip.start.strftime('%H.%M.%S')}"
|
|
26
|
+
f"-{clip.end.strftime('%H.%M.%S')}.mp4"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _copy_thumbnail(clip: IncomingClip, output_path: Path) -> str | None:
|
|
31
|
+
"""Copy the middle snapshot to the output dir, renamed to match the MP4 stem."""
|
|
32
|
+
if not clip.snapshots:
|
|
33
|
+
return None
|
|
34
|
+
src = clip.snapshots[len(clip.snapshots) // 2]
|
|
35
|
+
dst = output_path.with_suffix(".jpg")
|
|
36
|
+
try:
|
|
37
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
shutil.copy2(src, dst)
|
|
39
|
+
return str(dst)
|
|
40
|
+
except OSError:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _scan_camera(
|
|
45
|
+
cam: CameraConfig,
|
|
46
|
+
cfg: AppConfig,
|
|
47
|
+
db: aiosqlite.Connection,
|
|
48
|
+
pipeline: Pipeline,
|
|
49
|
+
) -> None:
|
|
50
|
+
loop = asyncio.get_running_loop()
|
|
51
|
+
adapter = make_adapter(cam.adapter)
|
|
52
|
+
clips = await loop.run_in_executor(None, list, adapter.scan(cam.root))
|
|
53
|
+
for clip in clips:
|
|
54
|
+
filename = _safe_filename(clip)
|
|
55
|
+
out = cfg.server.output_dir / cam.name / filename
|
|
56
|
+
await insert_clip(db, clip, cam.name, filename)
|
|
57
|
+
row = await get_clip_by_camera_filename(db, cam.name, filename)
|
|
58
|
+
if row and row["status"] == "pending":
|
|
59
|
+
clip_id = row["id"]
|
|
60
|
+
|
|
61
|
+
async def _done(
|
|
62
|
+
_cid: int = clip_id,
|
|
63
|
+
_clip: IncomingClip = clip,
|
|
64
|
+
_out: Path = out,
|
|
65
|
+
) -> None:
|
|
66
|
+
thumb = _copy_thumbnail(_clip, _out)
|
|
67
|
+
await set_clip_done(db, _cid, thumbnail=thumb)
|
|
68
|
+
|
|
69
|
+
await pipeline.enqueue(
|
|
70
|
+
TranscodeJob(
|
|
71
|
+
clip=clip,
|
|
72
|
+
output_path=out,
|
|
73
|
+
log_path=cfg.server.output_dir / "ffmpeg.log",
|
|
74
|
+
delete_source=cam.delete_after_transcode,
|
|
75
|
+
on_done=_done,
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _startup_scan(
|
|
81
|
+
cfg: AppConfig,
|
|
82
|
+
db: aiosqlite.Connection,
|
|
83
|
+
pipeline: Pipeline,
|
|
84
|
+
) -> None:
|
|
85
|
+
for cam in cfg.cameras:
|
|
86
|
+
await _scan_camera(cam, cfg, db, pipeline)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def _periodic_scan(
|
|
90
|
+
cfg: AppConfig,
|
|
91
|
+
db: aiosqlite.Connection,
|
|
92
|
+
pipeline: Pipeline,
|
|
93
|
+
) -> None:
|
|
94
|
+
while True:
|
|
95
|
+
await asyncio.sleep(float(cfg.server.scan_interval))
|
|
96
|
+
for cam in cfg.cameras:
|
|
97
|
+
await _scan_camera(cam, cfg, db, pipeline)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@asynccontextmanager
|
|
101
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
102
|
+
cfg: AppConfig = app.state.config
|
|
103
|
+
db: aiosqlite.Connection = app.state.db
|
|
104
|
+
pipeline: Pipeline = app.state.pipeline
|
|
105
|
+
await pipeline.start()
|
|
106
|
+
await _startup_scan(cfg, db, pipeline)
|
|
107
|
+
loop = asyncio.get_running_loop()
|
|
108
|
+
for cam in cfg.cameras:
|
|
109
|
+
|
|
110
|
+
async def _on_file(path: Path, *, _cam: CameraConfig = cam) -> None:
|
|
111
|
+
if path.suffix not in {".dav", ".mp4"}:
|
|
112
|
+
return
|
|
113
|
+
adapter = make_adapter(_cam.adapter)
|
|
114
|
+
clips = await loop.run_in_executor(None, list, adapter.scan(_cam.root))
|
|
115
|
+
match = next((c for c in clips if c.path == path), None)
|
|
116
|
+
if match is None:
|
|
117
|
+
return
|
|
118
|
+
filename = _safe_filename(match)
|
|
119
|
+
out = cfg.server.output_dir / _cam.name / filename
|
|
120
|
+
await insert_clip(db, match, _cam.name, filename)
|
|
121
|
+
row = await get_clip_by_camera_filename(db, _cam.name, filename)
|
|
122
|
+
if row and row["status"] == "pending":
|
|
123
|
+
clip_id = row["id"]
|
|
124
|
+
|
|
125
|
+
async def _done(
|
|
126
|
+
_cid: int = clip_id,
|
|
127
|
+
_clip: IncomingClip = match,
|
|
128
|
+
_out: Path = out,
|
|
129
|
+
) -> None:
|
|
130
|
+
thumb = _copy_thumbnail(_clip, _out)
|
|
131
|
+
await set_clip_done(db, _cid, thumbnail=thumb)
|
|
132
|
+
|
|
133
|
+
await pipeline.enqueue(
|
|
134
|
+
TranscodeJob(
|
|
135
|
+
clip=match,
|
|
136
|
+
output_path=out,
|
|
137
|
+
log_path=cfg.server.output_dir / "ffmpeg.log",
|
|
138
|
+
delete_source=_cam.delete_after_transcode,
|
|
139
|
+
on_done=_done,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
await pipeline.watch(cam.root, _on_file)
|
|
144
|
+
scan_task = asyncio.get_running_loop().create_task(
|
|
145
|
+
_periodic_scan(cfg, db, pipeline)
|
|
146
|
+
)
|
|
147
|
+
yield
|
|
148
|
+
scan_task.cancel()
|
|
149
|
+
try:
|
|
150
|
+
await scan_task
|
|
151
|
+
except asyncio.CancelledError:
|
|
152
|
+
pass
|
|
153
|
+
await pipeline.stop()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
cang/web/routes/clips.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import itertools
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
6
|
+
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
|
7
|
+
from fastapi.templating import Jinja2Templates
|
|
8
|
+
|
|
9
|
+
from cang.db import (
|
|
10
|
+
count_pending_for_day,
|
|
11
|
+
get_clip,
|
|
12
|
+
list_clips_for_day,
|
|
13
|
+
list_days_with_stats,
|
|
14
|
+
set_keep,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
router = APIRouter()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _templates(request: Request) -> Jinja2Templates:
|
|
21
|
+
return request.app.state.templates
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.get("/", response_class=RedirectResponse)
|
|
25
|
+
async def root() -> RedirectResponse:
|
|
26
|
+
return RedirectResponse(url="/days", status_code=307)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.get("/days", response_class=HTMLResponse)
|
|
30
|
+
async def days_list(request: Request) -> HTMLResponse:
|
|
31
|
+
db = request.app.state.db
|
|
32
|
+
days = await list_days_with_stats(db)
|
|
33
|
+
return _templates(request).TemplateResponse(request, "days.html", {"days": days})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.get("/days/{day}", response_class=HTMLResponse)
|
|
37
|
+
async def day_view(request: Request, day: str) -> HTMLResponse:
|
|
38
|
+
db = request.app.state.db
|
|
39
|
+
clips = await list_clips_for_day(db, day)
|
|
40
|
+
hours = {
|
|
41
|
+
hour: list(group)
|
|
42
|
+
for hour, group in itertools.groupby(clips, key=lambda c: c["recorded_at"][:13])
|
|
43
|
+
}
|
|
44
|
+
pending_count = await count_pending_for_day(db, day)
|
|
45
|
+
return _templates(request).TemplateResponse(
|
|
46
|
+
request,
|
|
47
|
+
"day.html",
|
|
48
|
+
{"day": day, "hours": hours, "pending_count": pending_count},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.get("/clips/{clip_id}/thumbnail")
|
|
53
|
+
async def clip_thumbnail(request: Request, clip_id: int) -> FileResponse:
|
|
54
|
+
db = request.app.state.db
|
|
55
|
+
clip = await get_clip(db, clip_id)
|
|
56
|
+
if clip is None or not clip["thumbnail"]:
|
|
57
|
+
raise HTTPException(status_code=404)
|
|
58
|
+
path = Path(clip["thumbnail"])
|
|
59
|
+
if not path.is_file():
|
|
60
|
+
raise HTTPException(status_code=404)
|
|
61
|
+
return FileResponse(path, media_type="image/jpeg")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@router.post("/clips/{clip_id}/keep", response_class=HTMLResponse)
|
|
65
|
+
async def toggle_keep(request: Request, clip_id: int) -> HTMLResponse:
|
|
66
|
+
db = request.app.state.db
|
|
67
|
+
clip = await get_clip(db, clip_id)
|
|
68
|
+
if clip is None:
|
|
69
|
+
raise HTTPException(status_code=404)
|
|
70
|
+
new_keep = not bool(clip["keep"])
|
|
71
|
+
await set_keep(db, clip_id, new_keep)
|
|
72
|
+
updated = await get_clip(db, clip_id)
|
|
73
|
+
return _templates(request).TemplateResponse(
|
|
74
|
+
request, "_keep_btn.html", {"clip": updated}
|
|
75
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.0"};Q.onLoad=$;Q.process=Dt;Q.on=be;Q.off=we;Q.trigger=he;Q.ajax=Hn;Q.find=r;Q.findAll=p;Q.closest=g;Q.remove=K;Q.addClass=W;Q.removeClass=o;Q.toggleClass=Y;Q.takeClass=ge;Q.swap=ze;Q.defineExtension=Un;Q.removeExtension=Bn;Q.logAll=z;Q.logNone=J;Q.parseInterval=d;Q._=_;const n={addTriggerHandler:Et,bodyContains:le,canAccessLocalStorage:j,findThisElement:Ee,filterValues:dn,swap:ze,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:T,getExpressionVars:Cn,getHeaders:hn,getInputValues:cn,getInternalData:ie,getSwapSpecification:pn,getTriggerSpecs:lt,getTarget:Ce,makeFragment:D,mergeObjects:ue,makeSettleInfo:xn,oobSwap:Te,querySelectorExt:fe,settleImmediately:Gt,shouldCancel:dt,triggerEvent:he,triggerErrorEvent:ae,withExtensions:Ut};const v=["get","post","put","delete","patch"];const R=v.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");const O=e("head");function e(e,t=false){return new RegExp(`<${e}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${e}>`,t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function H(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function T(e,t){while(e&&!t(e)){e=u(e)}return e||null}function q(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;T(t,function(e){return!!(r=q(t,ce(e),n))});if(r!=="unset"){return r}}function a(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function L(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function N(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function A(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function I(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function P(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function k(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(P(e)){const t=I(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){w(e)}finally{e.remove()}}})}function D(e){const t=e.replace(O,"");const n=L(t);let r;if(n==="html"){r=new DocumentFragment;const i=N(e);A(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=N(t);A(r,i.body);r.title=i.title}else{const i=N('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){k(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function M(e){return typeof e==="function"}function X(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e<t.length;e++){n.push(t[e])}}return n}function se(t,n){if(t){for(let e=0;e<t.length;e++){n(t[e])}}}function U(e){const t=e.getBoundingClientRect();const n=t.top;const r=t.bottom;return n<window.innerHeight&&r>=0}function le(e){const t=e.getRootNode&&e.getRootNode();if(t&&t instanceof window.ShadowRoot){return ne().body.contains(t.host)}else{return ne().body.contains(e)}}function B(e){return e.trim().split(/\s+/)}function ue(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){w(e);return null}}function j(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function V(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function _(e){return vn(ne().body,function(){return eval(e)})}function $(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function z(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function J(){Q.logger=null}function r(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return r(ne(),e)}}function p(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return p(ne(),e)}}function E(){return window}function K(e,t){e=y(e);if(t){E().setTimeout(function(){K(e);e=null},t)}else{u(e).removeChild(e)}}function ce(e){return e instanceof Element?e:null}function G(e){return e instanceof HTMLElement?e:null}function Z(e){return typeof e==="string"?e:null}function h(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function W(e,t,n){e=ce(y(e));if(!e){return}if(n){E().setTimeout(function(){W(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function o(e,t,n){let r=ce(y(e));if(!r){return}if(n){E().setTimeout(function(){o(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function Y(e,t){e=y(e);e.classList.toggle(t)}function ge(e,t){e=y(e);se(e.parentElement.children,function(e){o(e,t)});W(ce(e),t)}function g(e,t){e=ce(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||a(e,t)){return e}}while(e=e&&ce(u(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function pe(e,t){return e.substring(e.length-t.length)===t}function i(e){const t=e.trim();if(l(t,"<")&&pe(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(e,t,n){e=y(e);if(t.indexOf("closest ")===0){return[g(ce(e),i(t.substr(8)))]}else if(t.indexOf("find ")===0){return[r(h(e),i(t.substr(5)))]}else if(t==="next"){return[ce(e).nextElementSibling]}else if(t.indexOf("next ")===0){return[me(e,i(t.substr(5)),!!n)]}else if(t==="previous"){return[ce(e).previousElementSibling]}else if(t.indexOf("previous ")===0){return[ye(e,i(t.substr(9)),!!n)]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else if(t==="root"){return[H(e,!!n)]}else if(t.indexOf("global ")===0){return m(e,t.slice(7),true)}else{return F(h(H(e,!!n)).querySelectorAll(i(t)))}}var me=function(t,e,n){const r=h(H(t,n)).querySelectorAll(e);for(let e=0;e<r.length;e++){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_PRECEDING){return o}}};var ye=function(t,e,n){const r=h(H(t,n)).querySelectorAll(e);for(let e=r.length-1;e>=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function fe(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return r(h(t)||document,e)}else{return e}}function xe(e,t,n){if(M(t)){return{target:ne().body,event:Z(e),listener:t}}else{return{target:y(e),event:Z(t),listener:n}}}function be(t,n,r){_n(function(){const e=xe(t,n,r);e.target.addEventListener(e.event,e.listener)});const e=M(n);return e?n:r}function we(t,n,r){_n(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return M(n)?n:r}const ve=ne().createElement("output");function Se(e,t){const n=re(e,t);if(n){if(n==="this"){return[Ee(e,t)]}else{const r=m(e,n);if(r.length===0){w('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Ee(e,t){return ce(T(e,function(e){return te(ce(e),t)!=null}))}function Ce(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Ee(e,"hx-target")}else{return fe(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Re(t){const n=Q.config.attributesToSettle;for(let e=0;e<n.length;e++){if(t===n[e]){return true}}return false}function Oe(t,n){se(t.attributes,function(e){if(!n.hasAttribute(e.name)&&Re(e.name)){t.removeAttribute(e.name)}});se(n.attributes,function(e){if(Re(e.name)){t.setAttribute(e.name,e.value)}})}function He(t,e){const n=jn(e);for(let e=0;e<n.length;e++){const r=n[e];try{if(r.isInlineSwap(t)){return true}}catch(e){w(e)}}return t==="outerHTML"}function Te(e,o,i){let t="#"+ee(o,"id");let s="outerHTML";if(e==="true"){}else if(e.indexOf(":")>0){s=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{s=e}const n=ne().querySelectorAll(t);if(n){se(n,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!He(s,e)){t=h(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){_e(s,e,e,t,i)}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);ae(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function qe(e){se(p(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){e.parentNode.replaceChild(n,e)}})}function Le(l,e,u){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=h(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);u.tasks.push(function(){Oe(t,s)})}}})}function Ne(e){return function(){o(e,Q.config.addedClass);Dt(ce(e));Ae(h(e));he(e,"htmx:load")}}function Ae(e){const t="[autofocus]";const n=G(a(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function c(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;W(ce(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ne(o))}}}function Ie(e,t){let n=0;while(n<e.length){t=(t<<5)-t+e.charCodeAt(n++)|0}return t}function Pe(t){let n=0;if(t.attributes){for(let e=0;e<t.attributes.length;e++){const r=t.attributes[e];if(r.value){n=Ie(r.name,n);n=Ie(r.value,n)}}}return n}function ke(t){const n=ie(t);if(n.onHandlers){for(let e=0;e<n.onHandlers.length;e++){const r=n.onHandlers[e];we(t,r.event,r.listener)}delete n.onHandlers}}function De(e){const t=ie(e);if(t.timeout){clearTimeout(t.timeout)}if(t.listenerInfos){se(t.listenerInfos,function(e){if(e.on){we(e.on,e.trigger,e.listener)}})}ke(e);se(Object.keys(t),function(e){delete t[e]})}function f(e){he(e,"htmx:beforeCleanupElement");De(e);if(e.children){se(e.children,function(e){f(e)})}}function Me(t,e,n){let r;const o=t.previousSibling;c(u(t),t,e,n);if(o==null){r=u(t).firstChild}else{r=o.nextSibling}n.elts=n.elts.filter(function(e){return e!==t});while(r&&r!==t){if(r instanceof Element){n.elts.push(r);r=r.nextElementSibling}else{r=null}}f(t);if(t instanceof Element){t.remove()}else{t.parentNode.removeChild(t)}}function Xe(e,t,n){return c(e,e.firstChild,t,n)}function Fe(e,t,n){return c(u(e),e,t,n)}function Ue(e,t,n){return c(e,null,t,n)}function Be(e,t,n){return c(u(e),e.nextSibling,t,n)}function je(e){f(e);return u(e).removeChild(e)}function Ve(e,t,n){const r=e.firstChild;c(e,r,t,n);if(r){while(r.nextSibling){f(r.nextSibling);e.removeChild(r.nextSibling)}f(r);e.removeChild(r)}}function _e(t,e,n,r,o){switch(t){case"none":return;case"outerHTML":Me(n,r,o);return;case"afterbegin":Xe(n,r,o);return;case"beforebegin":Fe(n,r,o);return;case"beforeend":Ue(n,r,o);return;case"afterend":Be(n,r,o);return;case"delete":je(n);return;default:var i=jn(e);for(let e=0;e<i.length;e++){const s=i[e];try{const l=s.handleSwap(t,n,r,o);if(l){if(typeof l.length!=="undefined"){for(let e=0;e<l.length;e++){const u=l[e];if(u.nodeType!==Node.TEXT_NODE&&u.nodeType!==Node.COMMENT_NODE){o.tasks.push(Ne(u))}}}return}}catch(e){w(e)}}if(t==="innerHTML"){Ve(n,r,o)}else{_e(Q.config.defaultSwapStyle,e,n,r,o)}}}function $e(e,n){se(p(e,"[hx-swap-oob], [data-hx-swap-oob]"),function(e){if(Q.config.allowNestedOobSwaps||e.parentElement===null){const t=te(e,"hx-swap-oob");if(t!=null){Te(t,e,n)}}else{e.removeAttribute("hx-swap-oob");e.removeAttribute("data-hx-swap-oob")}})}function ze(e,t,r,o){if(!o){o={}}e=y(e);const n=document.activeElement;let i={};try{i={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const s=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=D(t);s.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t<u.length;t++){const c=u[t].split(":",2);let e=c[0].trim();if(e.indexOf("#")===0){e=e.substring(1)}const f=c[1]||"true";const a=n.querySelector("#"+e);if(a){Te(f,a,s)}}}$e(n,s);se(p(n,"template"),function(e){$e(e.content,s);if(e.content.childElementCount===0){e.remove()}});if(o.select){const h=ne().createDocumentFragment();se(n.querySelectorAll(o.select),function(e){h.appendChild(e)});n=h}qe(n);_e(r.swapStyle,o.contextElement,e,n,s)}if(i.elt&&!le(i.elt)&&ee(i.elt,"id")){const d=document.getElementById(ee(i.elt,"id"));const g={preventScroll:r.focusScroll!==undefined?!r.focusScroll:!Q.config.defaultFocusScroll};if(d){if(i.start&&d.setSelectionRange){try{d.setSelectionRange(i.start,i.end)}catch(e){}}d.focus(g)}}e.classList.remove(Q.config.swappingClass);se(s.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}he(e,"htmx:afterSwap",o.eventInfo)});if(o.afterSwapCallback){o.afterSwapCallback()}if(!r.ignoreTitle){Dn(s.title)}const l=function(){se(s.tasks,function(e){e.call()});se(s.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}he(e,"htmx:afterSettle",o.eventInfo)});if(o.anchor){const e=ce(y("#"+o.anchor));if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}bn(s.elts,r);if(o.afterSettleCallback){o.afterSettleCallback()}};if(r.settleDelay>0){E().setTimeout(l,r.settleDelay)}else{l()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(!X(e)){e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e<s.length;e++){he(n,s[e].trim(),[])}}}const Ke=/\s/;const x=/[\s,]/;const Ge=/[_$a-zA-Z]/;const Ze=/[_$a-zA-Z0-9]/;const We=['"',"'","/"];const Ye=/[^\s]/;const Qe=/[{(]/;const et=/[})]/;function tt(e){const t=[];let n=0;while(n<e.length){if(Ge.exec(e.charAt(n))){var r=n;while(Ze.exec(e.charAt(n+1))){n++}t.push(e.substr(r,n-r+1))}else if(We.indexOf(e.charAt(n))!==-1){const o=e.charAt(n);var r=n;n++;while(n<e.length&&e.charAt(n)!==o){if(e.charAt(n)==="\\"){n++}n++}t.push(e.substr(r,n-r+1))}else{const i=e.charAt(n);t.push(i)}n++}return t}function nt(e,t,n){return Ge.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function rt(r,o,i){if(o[0]==="["){o.shift();let e=1;let t=" return (function("+i+"){ return (";let n=null;while(o.length>0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){ae(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(nt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function b(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function ot(e){let t;if(e.length>0&&Qe.test(e[0])){e.shift();t=b(e,et).trim();e.shift()}else{t=b(e,x)}return t}const it="input, textarea, select";function st(e,t,n){const r=[];const o=tt(t);do{b(o,Ye);const l=o.length;const u=b(o,/[,\[\s]/);if(u!==""){if(u==="every"){const c={trigger:"every"};b(o,Ye);c.pollInterval=d(b(o,/[,\[\s]/));b(o,Ye);var i=rt(e,o,"event");if(i){c.eventFilter=i}r.push(c)}else{const f={trigger:u};var i=rt(e,o,"event");if(i){f.eventFilter=i}while(o.length>0&&o[0]!==","){b(o,Ye);const a=o.shift();if(a==="changed"){f.changed=true}else if(a==="once"){f.once=true}else if(a==="consume"){f.consume=true}else if(a==="delay"&&o[0]===":"){o.shift();f.delay=d(b(o,x))}else if(a==="from"&&o[0]===":"){o.shift();if(Qe.test(o[0])){var s=ot(o)}else{var s=b(o,x);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=ot(o);if(h.length>0){s+=" "+h}}}f.from=s}else if(a==="target"&&o[0]===":"){o.shift();f.target=ot(o)}else if(a==="throttle"&&o[0]===":"){o.shift();f.throttle=d(b(o,x))}else if(a==="queue"&&o[0]===":"){o.shift();f.queue=b(o,x)}else if(a==="root"&&o[0]===":"){o.shift();f[a]=ot(o)}else if(a==="threshold"&&o[0]===":"){o.shift();f[a]=b(o,x)}else{ae(e,"htmx:syntax:error",{token:o.shift()})}}r.push(f)}}if(o.length===l){ae(e,"htmx:syntax:error",{token:o.shift()})}b(o,Ye)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function lt(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||st(e,t,r)}if(n.length>0){return n}else if(a(e,"form")){return[{trigger:"submit"}]}else if(a(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(a(e,it)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function ut(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!pt(n,e,Xt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ft(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ht(t,n,e){if(t instanceof HTMLAnchorElement&&ft(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";if(r==="get"){}o=ee(t,"action")}e.forEach(function(e){mt(t,function(e,t){const n=ce(e);if(at(n)){f(n);return}de(r,o,n,t)},n,e,true)})}}function dt(e,t){const n=ce(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(a(n,'input[type="submit"], button')&&g(n,"form")!==null){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function gt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function pt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;ae(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function mt(s,l,e,u,c){const f=ie(s);let t;if(u.from){t=m(s,u.from)}else{t=[s]}if(u.changed){t.forEach(function(e){const t=ie(e);t.lastValue=e.value})}se(t,function(o){const i=function(e){if(!le(s)){o.removeEventListener(u.trigger,i);return}if(gt(s,e)){return}if(c||dt(e,s)){e.preventDefault()}if(pt(u,s,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(s)<0){t.handledFor.push(s);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!a(ce(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=ie(o);const r=o.value;if(n.lastValue===r){return}n.lastValue=r}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){l(s,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){l(s,e)},u.delay)}else{he(s,"htmx:trigger");l(s,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:i,on:o});o.addEventListener(u.trigger,i)})}let yt=false;let xt=null;function bt(){if(!xt){xt=function(){yt=true};window.addEventListener("scroll",xt);setInterval(function(){if(yt){yt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){wt(e)})}},200)}}function wt(e){if(!s(e,"data-hx-revealed")&&U(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function St(t,n,e){let i=false;se(v,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){Et(t,e,n,function(e,t){const n=ce(e);if(g(n,Q.config.disableSelector)){f(n);return}de(r,o,n,t)})})}});return i}function Et(r,e,t,n){if(e.trigger==="revealed"){bt();mt(r,n,t,e);wt(ce(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=fe(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e<t.length;e++){const n=t[e];if(n.isIntersecting){he(r,"intersect");break}}},o);i.observe(ce(r));mt(ce(r),n,t,e)}else if(e.trigger==="load"){if(!pt(e,r,Xt("load",{elt:r}))){vt(ce(r),n,t,e.delay)}}else if(e.pollInterval>0){t.polling=true;ct(ce(r),n,e)}else{mt(r,n,t,e)}}function Ct(e){const t=ce(e);if(!t){return false}const n=t.attributes;for(let e=0;e<n.length;e++){const r=n[e].name;if(l(r,"hx-on:")||l(r,"data-hx-on:")||l(r,"hx-on-")||l(r,"data-hx-on-")){return true}}return false}const Rt=(new XPathEvaluator).createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or'+' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function Ot(e,t){if(Ct(e)){t.push(ce(e))}const n=Rt.evaluate(e);let r=null;while(r=n.iterateNext())t.push(ce(r))}function Ht(e){const t=[];if(e instanceof DocumentFragment){for(const n of e.childNodes){Ot(n,t)}}else{Ot(e,t)}return t}function Tt(e){if(e.querySelectorAll){const n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";const r=[];for(const i in Xn){const s=Xn[i];if(s.getSelectors){var t=s.getSelectors();if(t){r.push(t)}}}const o=e.querySelectorAll(R+n+", form, [type='submit'],"+" [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(e=>", "+e).join(""));return o}else{return[]}}function qt(e){const t=g(ce(e.target),"button, input[type='submit']");const n=Nt(e);if(n){n.lastButtonClicked=t}}function Lt(e){const t=Nt(e);if(t){t.lastButtonClicked=null}}function Nt(e){const t=g(ce(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",qt);e.addEventListener("focusin",qt);e.addEventListener("focusout",Lt)}function It(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){ke(t);for(let e=0;e<t.attributes.length;e++){const n=t.attributes[e].name;const r=t.attributes[e].value;if(l(n,"hx-on")||l(n,"data-hx-on")){const o=n.indexOf("-on")+3;const i=n.slice(o,o+1);if(i==="-"||i===":"){let e=n.slice(o+1);if(l(e,":")){e="htmx"+e}else if(l(e,"-")){e="htmx:"+e.slice(1)}else if(l(e,"htmx-")){e="htmx:"+e.slice(5)}It(t,e,r)}}}}function kt(t){if(g(t,Q.config.disableSelector)){f(t);return}const n=ie(t);if(n.initHash!==Pe(t)){De(t);n.initHash=Pe(t);he(t,"htmx:beforeProcessNode");if(t.value){n.lastValue=t.value}const e=lt(t);const r=St(t,n,e);if(!r){if(re(t,"hx-boost")==="true"){ht(t,n,e)}else if(s(t,"hx-trigger")){e.forEach(function(e){Et(t,e,n,function(){})})}}if(t.tagName==="FORM"||ee(t,"type")==="submit"&&s(t,"form")){At(t)}he(t,"htmx:afterProcessNode")}}function Dt(e){e=y(e);if(g(e,Q.config.disableSelector)){f(e);return}kt(e);se(Tt(e),function(e){kt(e)});se(Ht(e),Pt)}function Mt(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function Xt(e,t){let n;if(window.CustomEvent&&typeof window.CustomEvent==="function"){n=new CustomEvent(e,{bubbles:true,cancelable:true,composed:true,detail:t})}else{n=ne().createEvent("CustomEvent");n.initCustomEvent(e,true,true,t)}return n}function ae(e,t,n){he(e,t,ue({error:t},n))}function Ft(e){return e==="htmx:afterProcessNode"}function Ut(e,t){se(jn(e),function(e){try{t(e)}catch(e){w(e)}})}function w(e){if(console.error){console.error(e)}else if(console.log){console.log("ERROR: ",e)}}function he(e,t,n){e=y(e);if(n==null){n={}}n.elt=e;const r=Xt(t,n);if(Q.logger&&!Ft(t)){Q.logger(e,t,n)}if(n.error){w(n.error);he(e,"htmx:error",{errorInfo:n})}let o=e.dispatchEvent(r);const i=Mt(t);if(o&&i!==t){const s=Xt(i,r.detail);o=o&&e.dispatchEvent(s)}Ut(ce(e),function(e){o=o&&(e.onEvent(t,r)!==false&&!r.defaultPrevented)});return o}let Bt=location.pathname+location.search;function jt(){const e=ne().querySelector("[hx-history-elt],[data-hx-history-elt]");return e||ne().body}function Vt(t,e){if(!j()){return}const n=$t(e);const r=ne().title;const o=window.scrollY;if(Q.config.historyCacheSize<=0){localStorage.removeItem("htmx-history-cache");return}t=V(t);const i=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<i.length;e++){if(i[e].url===t){i.splice(e,1);break}}const s={url:t,content:n,title:r,scroll:o};he(ne().body,"htmx:historyItemCreated",{item:s,cache:i});i.push(s);while(i.length>Q.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){ae(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function _t(t){if(!j()){return null}t=V(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<n.length;e++){if(n[e].url===t){return n[e]}}return null}function $t(e){const t=Q.config.requestClass;const n=e.cloneNode(true);se(p(n,"."+t),function(e){o(e,t)});return n.innerHTML}function zt(){const e=jt();const t=Bt||location.pathname+location.search;let n;try{n=ne().querySelector('[hx-history="false" i],[data-hx-history="false" i]')}catch(e){n=ne().querySelector('[hx-history="false"],[data-hx-history="false"]')}if(!n){he(ne().body,"htmx:beforeHistorySave",{path:t,historyElt:e});Vt(t,e)}if(Q.config.historyEnabled)history.replaceState({htmx:true},ne().title,window.location.href)}function Jt(e){if(Q.config.getCacheBusterParam){e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,"");if(pe(e,"&")||pe(e,"?")){e=e.slice(0,-1)}}if(Q.config.historyEnabled){history.pushState({htmx:true},"",e)}Bt=e}function Kt(e){if(Q.config.historyEnabled)history.replaceState({htmx:true},"",e);Bt=e}function Gt(e){se(e,function(e){e.call(undefined)})}function Zt(o){const e=new XMLHttpRequest;const i={path:o,xhr:e};he(ne().body,"htmx:historyCacheMiss",i);e.open("GET",o,true);e.setRequestHeader("HX-Request","true");e.setRequestHeader("HX-History-Restore-Request","true");e.setRequestHeader("HX-Current-URL",ne().location.href);e.onload=function(){if(this.status>=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=D(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=jt();const r=xn(n);Dn(e.title);Ve(n,t,r);Gt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{ae(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=_t(e);if(t){const n=D(t.content);const r=jt();const o=xn(r);Dn(n.title);Ve(r,n,o);Gt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Zt(e)}}}function Yt(e){let t=Se(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Qt(e){let t=Se(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function en(e,t){se(e,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function tn(t,n){for(let e=0;e<t.length;e++){const r=t[e];if(r.isSameNode(n)){return true}}return false}function nn(e){const t=e;if(t.name===""||t.name==null||t.disabled||g(t,"fieldset[disabled]")){return false}if(t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"){return false}if(t.type==="checkbox"||t.type==="radio"){return t.checked}return true}function rn(t,e,n){if(t!=null&&e!=null){if(Array.isArray(e)){e.forEach(function(e){n.append(t,e)})}else{n.append(t,e)}}}function on(t,n,r){if(t!=null&&n!=null){let e=r.getAll(t);if(Array.isArray(n)){e=e.filter(e=>n.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function sn(t,n,r,o,i){if(o==null||tn(t,o)){return}else{t.push(o)}if(nn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=F(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=F(o.files)}rn(s,e,n);if(i){ln(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){on(e.name,e.value,n)}else{t.push(e)}if(i){ln(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}rn(t,e,n)})}}function ln(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function un(t,e){for(const n of e.keys()){t.delete(n);e.getAll(n).forEach(function(e){t.append(n,e)})}return t}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){sn(n,o,i,g(e,"form"),l)}sn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const c=s.lastButtonClicked||e;const f=ee(c,"name");rn(f,c.value,o)}const u=Se(e,"hx-include");se(u,function(e){sn(n,r,i,ce(e),l);if(!a(e,"form")){se(h(e).querySelectorAll(it),function(e){sn(n,r,i,e,l)})}});un(r,o);return{errors:i,formData:r,values:An(r)}}function fn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=Ln(e);let n="";e.forEach(function(e,t){n=fn(n,t,e)});return n}function hn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};wn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function dn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.substr(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function gn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function pn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!gn(e)){r.show="top"}if(n){const s=B(n);if(s.length>0){for(let e=0;e<s.length;e++){const l=s[e];if(l.indexOf("swap:")===0){r.swapDelay=d(l.substr(5))}else if(l.indexOf("settle:")===0){r.settleDelay=d(l.substr(7))}else if(l.indexOf("transition:")===0){r.transition=l.substr(11)==="true"}else if(l.indexOf("ignoreTitle:")===0){r.ignoreTitle=l.substr(12)==="true"}else if(l.indexOf("scroll:")===0){const u=l.substr(7);var o=u.split(":");const c=o.pop();var i=o.length>0?o.join(":"):null;r.scroll=c;r.scrollTarget=i}else if(l.indexOf("show:")===0){const f=l.substr(5);var o=f.split(":");const a=o.pop();var i=o.length>0?o.join(":"):null;r.show=a;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.substr("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{w("Unknown modifier in hx-swap: "+l)}}}}return r}function mn(e){return re(e,"hx-encoding")==="multipart/form-data"||a(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function yn(t,n,r){let o=null;Ut(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(mn(n)){return un(new FormData,Ln(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function bn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ce(fe(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ce(fe(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function wn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.substr(11);t=true}else if(e.indexOf("js:")===0){e=e.substr(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return wn(ce(u(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{ae(e,"htmx:evalDisallowedError");return n}}function Sn(e,t){return wn(e,"hx-vars",true,t)}function En(e,t){return wn(e,"hx-vals",false,t)}function Cn(e){return ue(Sn(e),En(e))}function Rn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){ae(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function C(e,t){return t.test(e.getAllResponseHeaders())}function Hn(e,t,n){e=e.toLowerCase();if(n){if(n instanceof Element||typeof n==="string"){return de(e,t,null,null,{targetOverride:y(n),returnPromise:true})}else{return de(e,t,y(n.source),n.event,{handler:n.handler,headers:n.headers,values:n.values,targetOverride:y(n.target),swapOverride:n.swap,select:n.select,returnPromise:true})}}else{return de(e,t,null,null,{returnPromise:true})}}function Tn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function qn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ue({url:o,sameHost:r},n))}function Ln(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Nn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(r){return new Proxy(r,{get:function(e,t){if(typeof t==="symbol"){return Reflect.get(e,t)}if(t==="toJSON"){return()=>Object.fromEntries(r)}if(t in e){if(typeof e[t]==="function"){return function(){return r[t].apply(r,arguments)}}else{return e[t]}}const n=r.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Nn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Mn;const X=i.select||null;if(!le(r)){oe(s);return e}const u=i.targetOverride||ce(Ce(r));if(u==null||u==ve){ae(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let c=ie(r);const f=c.lastButtonClicked;if(f){const L=ee(f,"formaction");if(L!=null){n=L}const N=ee(f,"formmethod");if(N!=null){if(N.toLowerCase()!=="dialog"){t=N}}}const a=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:u,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:a};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const A=d.split(":");const I=A[0].trim();if(I==="this"){h=Ee(r,"hx-sync")}else{h=ce(fe(r,I))}d=(A[1]||"drop").trim();c=ie(h);if(d==="drop"&&c.xhr&&c.abortable!==true){oe(s);return e}else if(d==="abort"){if(c.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const Z=d.split(" ");g=(Z[1]||"last").trim()}}if(c.xhr){if(c.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(c.queuedRequests==null){c.queuedRequests=[]}if(g==="first"&&c.queuedRequests.length===0){c.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){c.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){c.queuedRequests=[];c.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;c.xhr=p;c.abortable=F;const m=function(){c.xhr=null;c.abortable=false;if(c.queuedRequests!=null&&c.queuedRequests.length>0){const e=c.queuedRequests.shift();e()}};const U=re(r,"hx-prompt");if(U){var y=prompt(U);if(y===null||!he(r,"htmx:prompt",{prompt:y,target:u})){oe(s);m();return e}}if(a&&!D){if(!confirm(a)){oe(s);m();return e}}let x=hn(r,u,y);if(t!=="get"&&!mn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=ue(x,i.headers)}const B=cn(r,t);let b=B.errors;const j=B.formData;if(i.values){un(j,Ln(i.values))}const V=Ln(Cn(r));const w=un(j,V);let v=dn(w,r);if(Q.config.getCacheBusterParam&&t==="get"){v.set("org.htmx.cache-buster",ee(u,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=wn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:v,parameters:An(v),unfilteredFormData:w,unfilteredParameters:An(w),headers:x,target:u,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;x=C.headers;v=Ln(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const $=n.split("#");const z=$[0];const R=$[1];let O=n;if(E){O=z;const W=!v.keys().next().done;if(W){if(O.indexOf("?")<0){O+="?"}else{O+="&"}O+=an(v);if(R){O+="#"+R}}}if(!qn(r,O,C)){ae(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),O,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in x){if(x.hasOwnProperty(k)){const Y=x[k];Rn(p,k,Y)}}}const H={xhr:p,target:u,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:O,responsePath:null,anchor:R}};p.onload=function(){try{const t=Tn(r);H.pathInfo.responsePath=On(p);M(r,H);en(T,q);he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){ae(r,"htmx:onLoadError",ue({error:e},H));throw e}};p.onerror=function(){en(T,q);ae(r,"htmx:afterRequest",H);ae(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){en(T,q);ae(r,"htmx:afterRequest",H);ae(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){en(T,q);ae(r,"htmx:afterRequest",H);ae(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Yt(r);var q=Qt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:yn(p,r,v);p.send(J);return e}function In(e,t){const n=t.xhr;let r=null;let o=null;if(C(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(C(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(C(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const u=re(e,"hx-replace-url");const c=ie(e).boosted;let f=null;let a=null;if(l){f="push";a=l}else if(u){f="replace";a=u}else if(c){f="push";a=s||i}if(a){if(a==="false"){return{}}if(a==="true"){a=s||i}if(t.pathInfo.anchor&&a.indexOf("#")===-1){a=a+"#"+t.pathInfo.anchor}return{type:f,path:a}}else{return{}}}function Pn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function kn(e){for(var t=0;t<Q.config.responseHandling.length;t++){var n=Q.config.responseHandling[t];if(Pn(n,e.status)){return n}}return{swap:false}}function Dn(e){if(e){const t=r("title");if(t){t.innerHTML=e}else{window.document.title=e}}}function Mn(o,i){const s=i.xhr;let l=i.target;const e=i.etc;const u=i.select;if(!he(o,"htmx:beforeOnLoad",i))return;if(C(s,/HX-Trigger:/i)){Je(s,"HX-Trigger",o)}if(C(s,/HX-Location:/i)){zt();let e=s.getResponseHeader("HX-Location");var t;if(e.indexOf("{")===0){t=S(e);e=t.path;delete t.path}Hn("get",e,t).then(function(){Jt(e)});return}const n=C(s,/HX-Refresh:/i)&&s.getResponseHeader("HX-Refresh")==="true";if(C(s,/HX-Redirect:/i)){location.href=s.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){location.reload();return}if(C(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ce(fe(o,s.getResponseHeader("HX-Retarget")))}}const c=In(o,i);const r=kn(s);const f=r.swap;let a=!!r.error;let h=Q.config.ignoreTitle||r.ignoreTitle;let d=r.select;if(r.target){i.target=ce(fe(o,r.target))}var g=e.swapOverride;if(g==null&&r.swapOverride){g=r.swapOverride}if(C(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ce(fe(o,s.getResponseHeader("HX-Retarget")))}}if(C(s,/HX-Reswap:/i)){g=s.getResponseHeader("HX-Reswap")}var p=s.response;var m=ue({shouldSwap:f,serverResponse:p,isError:a,ignoreTitle:h,selectOverride:d},i);if(r.event&&!he(l,r.event,m))return;if(!he(l,"htmx:beforeSwap",m))return;l=m.target;p=m.serverResponse;a=m.isError;h=m.ignoreTitle;d=m.selectOverride;i.target=l;i.failed=a;i.successful=!a;if(m.shouldSwap){if(s.status===286){ut(o)}Ut(o,function(e){p=e.transformResponse(p,s,o)});if(c.type){zt()}if(C(s,/HX-Reswap:/i)){g=s.getResponseHeader("HX-Reswap")}var y=pn(o,g);if(!y.hasOwnProperty("ignoreTitle")){y.ignoreTitle=h}l.classList.add(Q.config.swappingClass);let n=null;let r=null;if(u){d=u}if(C(s,/HX-Reselect:/i)){d=s.getResponseHeader("HX-Reselect")}const x=re(o,"hx-select-oob");const b=re(o,"hx-select");let e=function(){try{if(c.type){he(ne().body,"htmx:beforeHistoryUpdate",ue({history:c},i));if(c.type==="push"){Jt(c.path);he(ne().body,"htmx:pushedIntoHistory",{path:c.path})}else{Kt(c.path);he(ne().body,"htmx:replacedInHistory",{path:c.path})}}ze(l,p,y,{select:d||b,selectOOB:x,eventInfo:i,anchor:i.pathInfo.anchor,contextElement:o,afterSwapCallback:function(){if(C(s,/HX-Trigger-After-Swap:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Swap",e)}},afterSettleCallback:function(){if(C(s,/HX-Trigger-After-Settle:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Settle",e)}oe(n)}})}catch(e){ae(o,"htmx:swapError",i);oe(r);throw e}};let t=Q.config.globalViewTransitions;if(y.hasOwnProperty("transition")){t=y.transition}if(t&&he(o,"htmx:beforeTransition",i)&&typeof Promise!=="undefined"&&document.startViewTransition){const w=new Promise(function(e,t){n=e;r=t});const v=e;e=function(){document.startViewTransition(function(){v();return w})}}if(y.swapDelay>0){E().setTimeout(e,y.swapDelay)}else{e()}}if(a){ae(o,"htmx:responseError",ue({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Xn={};function Fn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Un(e,t){if(t.init){t.init(n)}Xn[e]=ue(Fn(),t)}function Bn(e){delete Xn[e]}function jn(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Xn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return jn(ce(u(e)),n,r)}var Vn=false;ne().addEventListener("DOMContentLoaded",function(){Vn=true});function _n(e){if(Vn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function $n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend","<style"+e+"> ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} </style>")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function Jn(){const e=zn();if(e){Q.config=ue(Q.config,e)}}_n(function(){Jn();$n();let e=ne().body;Dt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}();
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Cang</title>
|
|
7
|
+
<script src="/static/htmx.min.js"></script>
|
|
8
|
+
<style>
|
|
9
|
+
body { font-family: sans-serif; max-width: 1920px; margin: 0 auto; padding: 1rem; }
|
|
10
|
+
nav a { margin-right: 1rem; }
|
|
11
|
+
.clip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; }
|
|
12
|
+
.clip-tile { border: 1px solid #ccc; padding: 0.5rem; border-radius: 4px; }
|
|
13
|
+
.clip-tile video { width: 100%; display: block; }
|
|
14
|
+
.clip-meta { margin-bottom: 0.25rem; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
|
|
15
|
+
.clip-filename { font-size: 0.8rem; color: #555; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
16
|
+
.flag { font-size: 0.7rem; font-weight: bold; padding: 0.1rem 0.4rem; border-radius: 3px; }
|
|
17
|
+
.flag-motion { background: #fde8a0; color: #7a5a00; }
|
|
18
|
+
.flag-firstframe { background: #d0eaff; color: #00458a; }
|
|
19
|
+
.keep-btn { margin-top: 0.5rem; }
|
|
20
|
+
.hour-heading { margin-top: 1.5rem; font-size: 1.1rem; font-weight: bold; }
|
|
21
|
+
.days-list { list-style: none; padding: 0; }
|
|
22
|
+
.days-list li { padding: 0.3rem 0; border-bottom: 1px solid #eee; }
|
|
23
|
+
.day-stats { margin-left: 0.75rem; font-size: 0.85rem; color: #666; }
|
|
24
|
+
.day-layout { display: grid; grid-template-columns: 5rem 1fr; gap: 1rem; align-items: start; }
|
|
25
|
+
.hour-nav { position: sticky; top: 1rem; display: flex; flex-direction: column; gap: 0.5rem; padding-top: 0.25rem; }
|
|
26
|
+
.hour-nav a { font-size: 0.85rem; text-decoration: none; color: #555; }
|
|
27
|
+
.hour-nav a:hover { color: #000; text-decoration: underline; }
|
|
28
|
+
.clip-camera { font-size: 0.7rem; background: #e8e8e8; color: #333; padding: 0.1rem 0.4rem; border-radius: 3px; font-weight: bold; }
|
|
29
|
+
.processing-banner { background: #fff3cd; color: #856404; border: 1px solid #ffc107; border-radius: 4px; padding: 0.5rem 0.75rem; margin-bottom: 1rem; font-size: 0.9rem; }
|
|
30
|
+
</style>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<nav><a href="/days">All days</a></nav>
|
|
34
|
+
<main>
|
|
35
|
+
{% block content %}{% endblock %}
|
|
36
|
+
</main>
|
|
37
|
+
</body>
|
|
38
|
+
</html>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block content %}
|
|
3
|
+
<div class="day-layout">
|
|
4
|
+
{% if hours %}
|
|
5
|
+
<nav class="hour-nav">
|
|
6
|
+
{% for hour in hours %}<a href="#h{{ hour[-2:] }}">{{ hour[-2:] }}:00</a>
|
|
7
|
+
{% endfor %}
|
|
8
|
+
</nav>
|
|
9
|
+
{% else %}
|
|
10
|
+
<div></div>
|
|
11
|
+
{% endif %}
|
|
12
|
+
<div class="day-content">
|
|
13
|
+
<h1>{{ day }}</h1>
|
|
14
|
+
{% if pending_count %}
|
|
15
|
+
<div class="processing-banner">
|
|
16
|
+
{{ pending_count }} clip{% if pending_count != 1 %}s{% endif %} still being converted — refresh to see them when ready.
|
|
17
|
+
</div>
|
|
18
|
+
{% endif %}
|
|
19
|
+
{% if hours %}
|
|
20
|
+
{% for hour, clips in hours.items() %}
|
|
21
|
+
<div id="h{{ hour[-2:] }}" class="hour-heading">{{ hour[-2:] }}:00</div>
|
|
22
|
+
<div class="clip-grid">
|
|
23
|
+
{% for clip in clips %}
|
|
24
|
+
<div class="clip-tile">
|
|
25
|
+
<div class="clip-meta">
|
|
26
|
+
<span class="clip-camera">{{ clip["camera"] }}</span>
|
|
27
|
+
{% if 'M' in (clip['flags'] or '') %}<span class="flag flag-motion">Motion</span>{% endif %}
|
|
28
|
+
{% if 'F' in (clip['flags'] or '') %}<span class="flag flag-firstframe">First frame</span>{% endif %}
|
|
29
|
+
<span class="clip-filename">{{ clip["filename"] }}</span>
|
|
30
|
+
</div>
|
|
31
|
+
<video
|
|
32
|
+
src="/clips/video/{{ clip['camera'] }}/{{ clip['filename'] }}"
|
|
33
|
+
{% if clip['thumbnail'] %}poster="/clips/{{ clip['id'] }}/thumbnail"{% endif %}
|
|
34
|
+
controls
|
|
35
|
+
preload="none"
|
|
36
|
+
></video>
|
|
37
|
+
{% include "_keep_btn.html" %}
|
|
38
|
+
</div>
|
|
39
|
+
{% endfor %}
|
|
40
|
+
</div>
|
|
41
|
+
{% endfor %}
|
|
42
|
+
{% else %}
|
|
43
|
+
<p>No clips for this day.</p>
|
|
44
|
+
{% endif %}
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
{% endblock %}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block content %}
|
|
3
|
+
<h1>Days</h1>
|
|
4
|
+
{% if days %}
|
|
5
|
+
<ul class="days-list">
|
|
6
|
+
{% for day in days %}
|
|
7
|
+
<li>
|
|
8
|
+
<a href="/days/{{ day['day'] }}">{{ day['day'] }}</a>
|
|
9
|
+
<span class="day-stats">{{ day['clip_count'] }} clips · {{ day['motion_count'] }} motion · {{ day['total_hours'] }} h</span>
|
|
10
|
+
</li>
|
|
11
|
+
{% endfor %}
|
|
12
|
+
</ul>
|
|
13
|
+
{% else %}
|
|
14
|
+
<p>No clips yet.</p>
|
|
15
|
+
{% endif %}
|
|
16
|
+
{% endblock %}
|
|
@@ -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.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
cang/__init__.py,sha256=beloPmJPyBN-K2QxiQupHVfjIO4A1E9iQn_nLKI-Asg,115
|
|
2
|
+
cang/adapters/__init__.py,sha256=qdvDcN6nT7Uirr0oJ32YHsoLbwZ1TPAMw1udYGGLQfA,308
|
|
3
|
+
cang/adapters/base.py,sha256=owwMP59xfHOlfZEBkKiHLmUT1q1NSY1GmViGLEtHKtU,529
|
|
4
|
+
cang/adapters/dahua.py,sha256=9OwTO7AjdiZHwsZ7bCHnCT4xY8OpqV1rkxO0OWsO3Wk,3456
|
|
5
|
+
cang/cli.py,sha256=y2_Mv36noobe_kVuga57ymCuWMJRHNJR_qkYcg07tD0,946
|
|
6
|
+
cang/config.py,sha256=Ahj7FdedcJRX-VbxaD2lm4U0V5NnYrLAag3TykyzI0w,2668
|
|
7
|
+
cang/db.py,sha256=7X1qXuLReaa6xR6zPqUcmiGCMAY-YXzhpxRmxav6ReI,6181
|
|
8
|
+
cang/pipeline.py,sha256=AE94zNBsrnGgC4vAKACYNyzf0unOJB2SNN2Rp8HS2jQ,5128
|
|
9
|
+
cang/web/__init__.py,sha256=DtdEei_zJnUmpg8_Ry0-Y_iWsvF9aJ8Gew_RlzEP9cA,45
|
|
10
|
+
cang/web/app.py,sha256=RBJTQe9KIzNH-JUxcyXT4I5Cg3m0_s1De4W4w13Egn0,1143
|
|
11
|
+
cang/web/auth.py,sha256=RbfRQuSos0nfgdi87Zn65zg3FKiKnTGlV6E02eNJ4z4,1054
|
|
12
|
+
cang/web/lifespan.py,sha256=8rrxHN7ZTWqLyFYIly2CglvUvklwWBPAvgOygu2stvk,4918
|
|
13
|
+
cang/web/routes/__init__.py,sha256=DtdEei_zJnUmpg8_Ry0-Y_iWsvF9aJ8Gew_RlzEP9cA,45
|
|
14
|
+
cang/web/routes/clips.py,sha256=63kdcvoa5t0tyqLqRJl2MpaHiXorbkPzJ9HZaUM_6EM,2420
|
|
15
|
+
cang/web/static/htmx.min.js,sha256=D8V7oOZVUE0oK7bsHD2JJAzenyzhw5PVs4qVxbxtqHU,49082
|
|
16
|
+
cang/web/templates/_keep_btn.html,sha256=xaIBfY9hg7Jx94_n9fA6ahzp-wIdkbg99GNrlnBNRXY,172
|
|
17
|
+
cang/web/templates/base.html,sha256=5zFr-rIxR7U3HAiMh4LzPD7dUQYf50_XRtxUN3A-Nvw,2151
|
|
18
|
+
cang/web/templates/day.html,sha256=KWDbF33EJ9x9QDkWlr-qJEZX23ApPL0h7kC9t6na108,1631
|
|
19
|
+
cang/web/templates/days.html,sha256=I-HDcwULo5X9Smsnc9VLbmi7oifJH0UcKvr4g0US5Zo,424
|
|
20
|
+
cang-0.1.0.dist-info/WHEEL,sha256=Q9FtwzuR2QE37l-JIkuyklGnJJiCBHKnsPVQ9vzCMzQ,81
|
|
21
|
+
cang-0.1.0.dist-info/entry_points.txt,sha256=hnDkAJDUwjlGzpjtU9XB_LbzUs-nM16y1hf5MIWOIco,40
|
|
22
|
+
cang-0.1.0.dist-info/METADATA,sha256=btF6kcK-3zBY9w2t_bx0JxadEAteHC5LyCQDoAiNW30,2589
|
|
23
|
+
cang-0.1.0.dist-info/RECORD,,
|