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 ADDED
@@ -0,0 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ from importlib.metadata import version
3
+
4
+ __version__ = version("cang")
@@ -0,0 +1,9 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ from cang.adapters.base import BaseAdapter
3
+ from cang.adapters.dahua import DahuaAdapter
4
+
5
+
6
+ def make_adapter(adapter_type: str) -> BaseAdapter:
7
+ if adapter_type == "dahua":
8
+ return DahuaAdapter()
9
+ raise ValueError(f"unknown adapter: {adapter_type!r}")
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
@@ -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,6 @@
1
+ <button
2
+ class="keep-btn"
3
+ hx-post="/clips/{{ clip['id'] }}/keep"
4
+ hx-target="this"
5
+ hx-swap="outerHTML"
6
+ >{% if clip["keep"] %}Unkeep{% else %}Keep{% endif %}</button>
@@ -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 &mdash; 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 &middot; {{ day['motion_count'] }} motion &middot; {{ day['total_hours'] }}&thinsp;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: AGPL-3.0-or-later](https://img.shields.io/badge/licence-AGPL--3.0--or--later-blue)](LICENCE)
20
+ [![Status: Idea phase](https://img.shields.io/badge/status-idea%20phase-orange)]()
21
+ [![Woodpecker CI](https://ci.codeberg.org/api/badges/MinimalNVR/cang/status.svg)](https://ci.codeberg.org/MinimalNVR/cang)
22
+
23
+ **Cang** (藏, *cáng*, roughly "tsang" with a rising tone) — Chinese for "to store /
24
+ to archive". Secondary meaning: "hidden", quietly apt for security footage.
25
+ The name reflects the project's philosophy: cameras do all the clever work;
26
+ Cang just keeps what they send.
27
+
28
+ A lightweight NVR that does as little as possible: cameras handle motion detection
29
+ and object recognition themselves, deposit video clips and snapshot images into a
30
+ watched folder, and Cang transcodes, indexes, and serves them through a clean web UI
31
+ grouped by day and hour.
32
+
33
+ Inspired by [LazyNVR](https://codeberg.org/LazyNVR/lazynvr-sources), written in Python.
34
+
35
+ ## What it does
36
+
37
+ - Watches `incoming/<camera>/` directories for clips deposited by cameras
38
+ - Transcodes to H.264/MP4 with `-movflags +faststart` (browser-seekable)
39
+ - Indexes clips in SQLite: camera, timestamp, duration, thumbnail
40
+ - Serves a FastAPI + Jinja2 web UI: day → hour view, click-to-play
41
+ - Keeps clips marked as important; auto-expires the rest after configurable days
42
+ - Runs as a single Podman container; storage is a bind-mounted volume
43
+
44
+ ## What it does not do
45
+
46
+ - No RTSP / live-stream pulling
47
+ - No motion detection or object recognition (delegated to cameras)
48
+ - No multi-user roles or cloud sync
49
+
50
+ ## Status
51
+
52
+ Idea phase. See [concept.md](concept.md) for the full design specification.
53
+
54
+ ## Licence
55
+
56
+ Copyright (C) 2026 contributors
57
+
58
+ This program is free software: you can redistribute it and/or modify it under
59
+ the terms of the GNU Affero General Public License as published by the Free
60
+ Software Foundation, either version 3 of the License, or (at your option) any
61
+ later version.
62
+
63
+ See [LICENCE](LICENCE) for the full text.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.17
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ cang = cang.cli:main
3
+