Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion tests/test_cameras.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"""
from __future__ import annotations

import fnmatch

from viofosync_lib._archive import downloaded_filename_glob
from viofosync_lib.cameras import (
CAMERA_LETTERS,
Expand Down Expand Up @@ -52,7 +54,9 @@ def test_pair_slot_of_rear_fallback():


def test_glob_derivation():
assert downloaded_filename_glob.endswith(f"_*[{CAMERA_LETTERS}].MP4")
assert fnmatch.fnmatch("2026_0625_171242_0001F.MP4", downloaded_filename_glob)
assert fnmatch.fnmatch("20260625171242_000086.MP4", downloaded_filename_glob)
assert not fnmatch.fnmatch("20260625171242_000086.JPG", downloaded_filename_glob)


def test_js_mirror_matches_registry():
Expand Down
74 changes: 74 additions & 0 deletions tests/test_queue_day_grouping.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Queue day grouping for compact Viofo filenames."""
from __future__ import annotations

import datetime as dt
import time

from web.db import Database
from web.services import queue


def _recorded_at(day: int = 25) -> int:
return int(
dt.datetime(2026, 6, day, 17, 12, 42, tzinfo=dt.UTC).timestamp()
)


def test_compact_filename_uses_filename_date_for_day_grouping(tmp_path):
db = Database(str(tmp_path / "test.db"))
now = int(time.time())
with db.write() as c:
c.execute(
"""
INSERT INTO download_queue (
filename, source_dir, remote_size, recorded_at,
camera, event_type, state, enqueued_at
) VALUES (?,?,?,?,?,?,?,?)
""",
(
"20260625171242_000086.MP4",
"/DCIM/Movie",
1024,
_recorded_at(),
"F",
"normal",
"pending",
now,
),
)

days = queue.list_days(db)
assert [d["day"] for d in days] == ["2026-06-25"]

items = queue.list_day_items(db, "2026-06-25")
assert len(items) == 1
assert items[0]["kind_camera"] == "F"
assert items[0]["kind_event"] == "normal"


def test_compact_filename_day_grouping_ignores_timestamp_timezone_shift(tmp_path):
db = Database(str(tmp_path / "test.db"))
now = int(time.time())
with db.write() as c:
c.execute(
"""
INSERT INTO download_queue (
filename, source_dir, remote_size, recorded_at,
camera, event_type, state, enqueued_at
) VALUES (?,?,?,?,?,?,?,?)
""",
(
"20260625171242_000086.MP4",
"/DCIM/Movie",
1024,
_recorded_at(day=26),
"F",
"normal",
"pending",
now,
),
)

assert [d["day"] for d in queue.list_days(db)] == ["2026-06-25"]
assert len(queue.list_day_items(db, "2026-06-25")) == 1
assert queue.list_day_items(db, "2026-06-26") == []
18 changes: 18 additions & 0 deletions tests/test_queue_filename_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,30 @@
"""
from __future__ import annotations

from viofosync_lib import downloaded_filename_re
from web.services.queue import (
_camera_from_filename,
_event_from_filename,
)


def test_compact_single_channel_filename_defaults_to_front_normal():
filename = "20260625171242_000086.MP4"
assert _camera_from_filename(filename) == "F"
assert _event_from_filename(filename) == "normal"

m = downloaded_filename_re.match(filename)
assert m is not None
assert m.group("year") == "2026"
assert m.group("month") == "06"
assert m.group("day") == "25"
assert m.group("hour") == "17"
assert m.group("minute") == "12"
assert m.group("second") == "42"
assert m.group("sequence") == "000086"
assert m.group("camera") is None


def test_camera_front_rear():
assert _camera_from_filename("2026_0513_172152_000120F.MP4") == "F"
assert _camera_from_filename("2026_0513_172152_000121R.MP4") == "R"
Expand Down
27 changes: 27 additions & 0 deletions tests/test_scanner_compact_filename.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Scanner support for compact Viofo filenames."""
from __future__ import annotations

from web.db import Database
from web.services import scanner


def test_scanner_indexes_compact_single_channel_filename(
tmp_path,
tmp_recordings_dir,
):
db = Database(str(tmp_path / "test.db"))
day_dir = tmp_recordings_dir / "2026-06-25"
day_dir.mkdir()
filename = "20260625171242_000086.MP4"
(day_dir / filename).write_bytes(b"clip")

count = scanner.scan(db, str(tmp_recordings_dir), "daily")

assert count == 1
with db.conn() as c:
row = c.execute("SELECT * FROM clip_index").fetchone()
assert row["basename"] == filename
assert row["group_name"] == "2026-06-25"
assert row["camera"] == "F"
assert row["event_type"] == "normal"
assert row["sequence"] == 86
17 changes: 7 additions & 10 deletions viofosync_lib/_archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@
import re
from collections import namedtuple

from .cameras import CAMERA_LETTERS

logger = logging.getLogger("viofosync_lib.archive")

# Recording namedtuple matching Viofo's file information.
Expand All @@ -32,19 +30,18 @@
"yearly": "[0-9][0-9][0-9][0-9]",
}

# Downloaded recording filename glob pattern. The trailing
# letter is the camera (see cameras.py for the registry).
# Downloaded recording filename glob pattern. Some Viofo models write
# files as ``YYYYMMDDHHMMSS_NNNNNN.MP4`` without a camera suffix, while
# newer/multi-channel models use ``YYYY_MMDD_HHMMSS_NNNN[FRTI].MP4``.
downloaded_filename_glob = (
"[0-9][0-9][0-9][0-9]_[0-9][0-9][0-9][0-9]"
"_[0-9][0-9][0-9][0-9][0-9][0-9]"
f"_*[{CAMERA_LETTERS}].MP4"
"[0-9][0-9][0-9][0-9]*_[0-9]*.MP4"
)

# Downloaded recording filename regular expression.
downloaded_filename_re = re.compile(
r"^(?P<year>\d{4})_(?P<month>\d{2})(?P<day>\d{2})"
r"_(?P<hour>\d{2})(?P<minute>\d{2})(?P<second>\d{2})"
r"_(?P<sequence>\d+)(?P<camera>.+)\.MP4$",
r"^(?P<year>\d{4})_?(?P<month>\d{2})(?P<day>\d{2})"
r"_?(?P<hour>\d{2})(?P<minute>\d{2})(?P<second>\d{2})"
r"_(?P<sequence>\d+)(?P<camera>[PE]?[FRTI])?\.MP4$",
re.IGNORECASE,
)

Expand Down
75 changes: 47 additions & 28 deletions web/services/queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from dataclasses import dataclass
from typing import Iterable, List, Optional

from viofosync_lib import downloaded_filename_re
from viofosync_lib.cameras import CAMERA_LETTERS

from ..db import Database
Expand Down Expand Up @@ -186,27 +187,24 @@ def reconcile(
def _camera_from_filename(filename: str) -> Optional[str]:
# Handles both ``…_0001F.MP4`` and ``…_0001PF.MP4`` /
# ``…_0001EF.MP4`` — the optional prefix letter encodes the
# event type (P=parking, E=event); the camera letter set
# comes from the registry.
import re as _re
m = _re.match(
rf"^\d{{4}}_\d{{4}}_\d{{6}}_\d+[PE]?([{CAMERA_LETTERS}])\.MP4$",
filename,
_re.IGNORECASE,
)
return m.group(1).upper() if m else None
# event type (P=parking, E=event). Older Viofo filenames may omit
# the camera letter entirely; treat those as front-camera clips.
m = downloaded_filename_re.match(filename)
if not m:
return None
suffix = (m.group("camera") or "").upper()
if not suffix:
return "F"
camera = suffix[-1]
return camera if camera in CAMERA_LETTERS else None


def _event_from_filename(filename: str) -> Optional[str]:
import re as _re
m = _re.match(
rf"^\d{{4}}_\d{{4}}_\d{{6}}_\d+([PE])?[{CAMERA_LETTERS}]\.MP4$",
filename,
_re.IGNORECASE,
)
m = downloaded_filename_re.match(filename)
if not m:
return None
prefix = (m.group(1) or "").upper()
suffix = (m.group("camera") or "").upper()
prefix = suffix[-2] if len(suffix) >= 2 else ""
return {"P": "parking", "E": "event"}.get(prefix, "normal")


Expand All @@ -216,8 +214,11 @@ def _event_from_filename(filename: str) -> Optional[str]:
# Filenames end in ``…NNNNN[PE]?[FRTI].MP4`` — the camera letter
# is the character immediately before ``.MP4``, and the byte
# before that is either a digit (normal) or P/E.
_CAM_SQL = "upper(substr(filename, -5, 1))"
_EVT_PREFIX_SQL = "upper(substr(filename, -6, 1))"
_CAM_SQL = "COALESCE(NULLIF(camera, ''), 'F')"
_EVT_PREFIX_SQL = (
"CASE event_type WHEN 'parking' THEN 'P' "
"WHEN 'event' THEN 'E' ELSE '' END"
)


def next_pending(
Expand Down Expand Up @@ -443,14 +444,22 @@ def list_page(


def _day_expr() -> str:
"""SQL expression for the YYYY-MM-DD day key derived from
the filename (``YYYY_MMDD_HHMMSS_NN<cam>.MP4``). Uses the
filename rather than ``recorded_at`` so grouping is
consistent even for rows missing a timestamp."""
"""SQL expression for the YYYY-MM-DD day key.

Prefer the dashcam filename date so day grouping stays independent of
server/browser timezone. Fall back to recorded_at for legacy rows whose
filenames cannot be parsed by either Viofo naming style.
"""
return (
"substr(filename,1,4) || '-' || "
"substr(filename,6,2) || '-' || "
"substr(filename,8,2)"
"CASE "
"WHEN filename GLOB '[0-9][0-9][0-9][0-9]_[0-9][0-9][0-9][0-9]_*' "
"THEN substr(filename,1,4) || '-' || substr(filename,6,2) || '-' || substr(filename,8,2) "
"WHEN filename GLOB '[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]*_*' "
"THEN substr(filename,1,4) || '-' || substr(filename,5,2) || '-' || substr(filename,7,2) "
"WHEN recorded_at IS NOT NULL "
"THEN strftime('%Y-%m-%d', recorded_at, 'unixepoch') "
"ELSE substr(filename,1,4) || '-' || "
"substr(filename,6,2) || '-' || substr(filename,8,2) END"
)


Expand All @@ -477,7 +486,12 @@ def _kind_filters(
both aliased and unaliased queries.
"""
prefix = f"{alias}." if alias else ""
evt = _EVT_PREFIX_SQL.replace("filename", f"{prefix}filename")
evt = (
f"CASE {prefix}event_type "
"WHEN 'parking' THEN 'P' "
"WHEN 'event' THEN 'E' "
"ELSE '' END"
)
ro_expr = _RO_SQL.replace("source_dir", f"{prefix}source_dir")

if driving and parking and ro:
Expand Down Expand Up @@ -581,8 +595,13 @@ def list_day_items(
params.extend(kind_params)
where = "WHERE " + " AND ".join(clauses)

cam_dq = _CAM_SQL.replace("filename", "dq.filename")
evt_dq = _EVT_PREFIX_SQL.replace("filename", "dq.filename")
cam_dq = "COALESCE(NULLIF(dq.camera, ''), 'F')"
evt_dq = (
"CASE dq.event_type "
"WHEN 'parking' THEN 'P' "
"WHEN 'event' THEN 'E' "
"ELSE '' END"
)
ro_dq = _RO_SQL.replace("source_dir", "dq.source_dir")

with db.conn() as c:
Expand Down
6 changes: 3 additions & 3 deletions web/services/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,14 @@ def _clip_meta_for(
if not os.path.isfile(path):
return None

camera_field = m.group("camera")
camera_field = (m.group("camera") or "F").upper()
camera = camera_field[-1] if camera_field[-1:] in "FRTI" else "F"
return ClipMeta(
path=path,
basename=filename,
group_name=ts.strftime("%Y-%m-%d"), # always daily key in UI
timestamp=ts,
camera=camera_field.upper(),
camera=camera,
sequence=int(m.group("sequence")),
event_type=_event_type_for(camera_field, source_dir),
size_bytes=os.path.getsize(path),
Expand Down Expand Up @@ -270,4 +271,3 @@ def scan(db: Database, destination: str, grouping: str, hub=None, loop=None) ->
_dq.enqueue_missing(db, priority=1, now=int(time.time()))

return len(seen_paths)

15 changes: 9 additions & 6 deletions web/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1848,13 +1848,16 @@ function renderPagination(total) {
// ---------- Downloads ----------

// Two-digit hour ("00".."23") for a queue item, derived from the
// dashcam filename (YYYY_MMDD_HHMMSS_…). Filename-derived (not
// recorded_at) to stay timezone-stable and consistent with the
// server's day grouping (_day_expr in services/queue.py). Names that
// don't match bucket under "??" so they stay visible and sort last.
// dashcam filename. Supports both YYYY_MMDD_HHMMSS_... and compact
// YYYYMMDDHHMMSS_... names. Filename-derived (not recorded_at) to stay
// timezone-stable and consistent with the server's day grouping.
// Names that don't match bucket under "??" so they stay visible and sort last.
function hourKeyForItem(it) {
const m = /^\d{4}_\d{4}_(\d{2})/.exec(it.filename || "");
return m ? m[1] : "??";
const name = it.filename || "";
const separated = /^\d{4}_\d{4}_(\d{2})/.exec(name);
if (separated) return separated[1];
const compact = /^\d{8}(\d{2})\d{4}_/.exec(name);
return compact ? compact[1] : "??";
}

// Bucket a day's items by hour. Returns [{ hour, items }] with hours
Expand Down
Loading