xmcam-fs 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ name: Test, Build and Publish
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - 'main'
7
+ tags:
8
+ - 'v*'
9
+ pull_request:
10
+ branches:
11
+ - 'main'
12
+
13
+ jobs:
14
+ test:
15
+ name: Test and Lint
16
+ runs-on: ubuntu-latest
17
+ strategy:
18
+ fail-fast: false
19
+ matrix:
20
+ python-version: ["3.10", "3.11", "3.12"]
21
+
22
+ steps:
23
+ - name: Checkout repository
24
+ uses: actions/checkout@v4
25
+
26
+ - name: Set up Python ${{ matrix.python-version }}
27
+ uses: actions/setup-python@v5
28
+ with:
29
+ python-version: ${{ matrix.python-version }}
30
+
31
+ - name: Install system deps (libfuse3 for pyfuse3)
32
+ run: sudo apt-get update && sudo apt-get install -y libfuse3-dev pkg-config
33
+
34
+ - name: Install dependencies
35
+ run: |
36
+ python -m pip install --upgrade pip
37
+ pip install -e ".[dev]"
38
+
39
+ - name: Lint with ruff
40
+ run: ruff check .
41
+
42
+ - name: Check formatting with Black
43
+ run: black --check .
44
+
45
+ - name: Type check with Mypy
46
+ run: mypy src
47
+
48
+ - name: Test with pytest
49
+ run: pytest
50
+
51
+ publish:
52
+ name: Build and Publish to PyPI
53
+ needs: test
54
+ runs-on: ubuntu-latest
55
+ if: startsWith(github.ref, 'refs/tags/v')
56
+ permissions:
57
+ id-token: write # OIDC authentication with PyPI (trusted publishing)
58
+
59
+ steps:
60
+ - name: Checkout repository
61
+ uses: actions/checkout@v4
62
+ with:
63
+ fetch-depth: 0
64
+
65
+ - name: Set up Python
66
+ uses: actions/setup-python@v5
67
+ with:
68
+ python-version: '3.x'
69
+
70
+ - name: Install build tools
71
+ run: python -m pip install --upgrade build
72
+
73
+ - name: Build package
74
+ run: python -m build
75
+
76
+ - name: Publish package to PyPI
77
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,23 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+
12
+ # FUSE cache / downloaded footage (never commit)
13
+ *.xmraw
14
+ *.mp4
15
+ cache/
16
+ .xmcam-cache/
17
+ mnt/
18
+ mountpoint/
19
+
20
+ # Local secrets / scratch
21
+ .env
22
+ secrets.*
23
+ _scratch/
xmcam_fs-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 theundefined
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.5
2
+ Name: xmcam-fs
3
+ Version: 0.1.0
4
+ Summary: Mount a Xiongmai / iCSee / XMeye IP camera as a FUSE filesystem and browse its recordings as files (MP4 or raw), sshfs-style. No cloud.
5
+ Project-URL: Homepage, https://github.com/theundefined/xmcam-fs
6
+ Project-URL: Repository, https://github.com/theundefined/xmcam-fs
7
+ Project-URL: Issues, https://github.com/theundefined/xmcam-fs/issues
8
+ Author-email: theundefined <undefine@aramin.net>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cctv,dvrip,filesystem,fuse,icsee,ip-camera,mount,nvr,onvif,pyfuse3,recordings,xiongmai,xm,xmeye
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Topic :: Multimedia :: Video
20
+ Classifier: Topic :: System :: Filesystems
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: pyfuse3>=3.2.0
23
+ Requires-Dist: trio>=0.22
24
+ Requires-Dist: xmcam-py>=0.1.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: black>=24.0.0; extra == 'dev'
27
+ Requires-Dist: build>=1.0.0; extra == 'dev'
28
+ Requires-Dist: mypy>=1.9.0; extra == 'dev'
29
+ Requires-Dist: pytest>=7; extra == 'dev'
30
+ Requires-Dist: ruff>=0.3.0; extra == 'dev'
31
+ Requires-Dist: twine>=5.0.0; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # xmcam-fs
35
+
36
+ **Mount a Xiongmai (XM) / iCSee / XMeye IP camera as a filesystem** and browse
37
+ its recordings as ordinary files — like `sshfs`, but for your camera's SD
38
+ card/disk. Built on [`xmcam`](https://github.com/theundefined/xmcam-py) and
39
+ [`pyfuse3`](https://github.com/libfuse/pyfuse3). Everything stays on the local
40
+ network: **no cloud, no internet.**
41
+
42
+ ```
43
+ mnt/
44
+ └── 2026-11-10/
45
+ ├── mp4/
46
+ │ ├── 08.00.00-08.05.00.mp4 # playable MP4, produced on first open
47
+ │ └── 08.05.00-08.09.17.mp4
48
+ └── raw/
49
+ ├── 08.00.00-08.05.00.xmraw # original Xiongmai container
50
+ └── 08.05.00-08.09.17.xmraw
51
+ ```
52
+
53
+ Each recording appears twice:
54
+
55
+ - **`mp4/`** — a playable MP4 (HEVC/H.264, stream-copied, no re-encode). The
56
+ first time you open one, the whole segment is downloaded and converted, then
57
+ cached — so the first read is slow (~2 min for a 5-minute segment on these
58
+ cameras) and later reads are instant.
59
+ - **`raw/`** — the original `.xmraw` container, exact size, no conversion cost.
60
+
61
+ The filesystem is **read-only**.
62
+
63
+ ## Why a filesystem?
64
+
65
+ Because then every tool you already have just works: `cp`, `rsync`, `mpv
66
+ mnt/2026-11-10/mp4/08.00.00-08.05.00.mp4`, a file manager, a backup script.
67
+ No bespoke download command needed.
68
+
69
+ ## Requirements
70
+
71
+ - Linux with FUSE 3 (`/dev/fuse`, `fusermount3`).
72
+ - **`libfuse3-dev`** and `pkg-config` are needed to *build* `pyfuse3` (its only
73
+ non-Python dependency). On Debian/Ubuntu: `sudo apt install libfuse3-dev pkg-config`.
74
+ - `ffmpeg` for the `mp4/` view (the `raw/` view works without it).
75
+
76
+ ## Install
77
+
78
+ ```bash
79
+ pipx install xmcam-fs # or: pip install xmcam-fs
80
+ ```
81
+
82
+ ## Usage
83
+
84
+ ```bash
85
+ # Mount the camera (credentials auto-recovered from anonymous ONVIF if omitted)
86
+ xmcam-fs 192.0.2.10 ./mnt
87
+
88
+ # Only index the last 3 days, foreground (Ctrl-C to unmount)
89
+ xmcam-fs 192.0.2.10 ./mnt --days 3 --foreground
90
+
91
+ # With explicit credentials and the smaller sub-stream
92
+ XMCAM_USER=... XMCAM_PASS=... xmcam-fs 192.0.2.10 ./mnt --stream Sub
93
+
94
+ # Unmount
95
+ fusermount3 -u ./mnt
96
+ ```
97
+
98
+ Options: `--days N` (how many past days to index, default 7), `--stream Main|Sub`,
99
+ `--channel N`, `--cache-dir PATH` (default under `$XDG_CACHE_HOME`),
100
+ `--user/--password`, `--foreground`, `--debug`.
101
+
102
+ ## How it works
103
+
104
+ On mount, `xmcam-fs` recovers the camera account (anonymous ONVIF), lists the
105
+ recordings for the chosen window (DVRIP `OPFileQuery`) and builds a read-only
106
+ tree. `ls` is cheap — it never downloads. Opening a file triggers a one-off
107
+ download (DVRIP `OPPlayBack`) in a worker thread; for the `mp4/` view the
108
+ Xiongmai container is demuxed and remuxed to MP4. Results are cached on disk, so
109
+ re-opening is instant.
110
+
111
+ > The recording list is a snapshot taken at mount time. Remount to pick up
112
+ > newer footage. (Live refresh is planned.)
113
+
114
+ ## Security & ethics
115
+
116
+ `xmcam-fs` can recover camera credentials and pull footage over the LAN because
117
+ the firmware exposes them to anonymous ONVIF. **Use it only on cameras you own
118
+ or are authorised to administer.** No cloud is ever contacted and no credentials
119
+ are written to disk (they are recovered per-run and kept in memory). The on-disk
120
+ cache under `--cache-dir` holds decoded footage — put it somewhere appropriate
121
+ and clear it when done.
122
+
123
+ ## License
124
+
125
+ MIT © theundefined
@@ -0,0 +1,92 @@
1
+ # xmcam-fs
2
+
3
+ **Mount a Xiongmai (XM) / iCSee / XMeye IP camera as a filesystem** and browse
4
+ its recordings as ordinary files — like `sshfs`, but for your camera's SD
5
+ card/disk. Built on [`xmcam`](https://github.com/theundefined/xmcam-py) and
6
+ [`pyfuse3`](https://github.com/libfuse/pyfuse3). Everything stays on the local
7
+ network: **no cloud, no internet.**
8
+
9
+ ```
10
+ mnt/
11
+ └── 2026-11-10/
12
+ ├── mp4/
13
+ │ ├── 08.00.00-08.05.00.mp4 # playable MP4, produced on first open
14
+ │ └── 08.05.00-08.09.17.mp4
15
+ └── raw/
16
+ ├── 08.00.00-08.05.00.xmraw # original Xiongmai container
17
+ └── 08.05.00-08.09.17.xmraw
18
+ ```
19
+
20
+ Each recording appears twice:
21
+
22
+ - **`mp4/`** — a playable MP4 (HEVC/H.264, stream-copied, no re-encode). The
23
+ first time you open one, the whole segment is downloaded and converted, then
24
+ cached — so the first read is slow (~2 min for a 5-minute segment on these
25
+ cameras) and later reads are instant.
26
+ - **`raw/`** — the original `.xmraw` container, exact size, no conversion cost.
27
+
28
+ The filesystem is **read-only**.
29
+
30
+ ## Why a filesystem?
31
+
32
+ Because then every tool you already have just works: `cp`, `rsync`, `mpv
33
+ mnt/2026-11-10/mp4/08.00.00-08.05.00.mp4`, a file manager, a backup script.
34
+ No bespoke download command needed.
35
+
36
+ ## Requirements
37
+
38
+ - Linux with FUSE 3 (`/dev/fuse`, `fusermount3`).
39
+ - **`libfuse3-dev`** and `pkg-config` are needed to *build* `pyfuse3` (its only
40
+ non-Python dependency). On Debian/Ubuntu: `sudo apt install libfuse3-dev pkg-config`.
41
+ - `ffmpeg` for the `mp4/` view (the `raw/` view works without it).
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pipx install xmcam-fs # or: pip install xmcam-fs
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ ```bash
52
+ # Mount the camera (credentials auto-recovered from anonymous ONVIF if omitted)
53
+ xmcam-fs 192.0.2.10 ./mnt
54
+
55
+ # Only index the last 3 days, foreground (Ctrl-C to unmount)
56
+ xmcam-fs 192.0.2.10 ./mnt --days 3 --foreground
57
+
58
+ # With explicit credentials and the smaller sub-stream
59
+ XMCAM_USER=... XMCAM_PASS=... xmcam-fs 192.0.2.10 ./mnt --stream Sub
60
+
61
+ # Unmount
62
+ fusermount3 -u ./mnt
63
+ ```
64
+
65
+ Options: `--days N` (how many past days to index, default 7), `--stream Main|Sub`,
66
+ `--channel N`, `--cache-dir PATH` (default under `$XDG_CACHE_HOME`),
67
+ `--user/--password`, `--foreground`, `--debug`.
68
+
69
+ ## How it works
70
+
71
+ On mount, `xmcam-fs` recovers the camera account (anonymous ONVIF), lists the
72
+ recordings for the chosen window (DVRIP `OPFileQuery`) and builds a read-only
73
+ tree. `ls` is cheap — it never downloads. Opening a file triggers a one-off
74
+ download (DVRIP `OPPlayBack`) in a worker thread; for the `mp4/` view the
75
+ Xiongmai container is demuxed and remuxed to MP4. Results are cached on disk, so
76
+ re-opening is instant.
77
+
78
+ > The recording list is a snapshot taken at mount time. Remount to pick up
79
+ > newer footage. (Live refresh is planned.)
80
+
81
+ ## Security & ethics
82
+
83
+ `xmcam-fs` can recover camera credentials and pull footage over the LAN because
84
+ the firmware exposes them to anonymous ONVIF. **Use it only on cameras you own
85
+ or are authorised to administer.** No cloud is ever contacted and no credentials
86
+ are written to disk (they are recovered per-run and kept in memory). The on-disk
87
+ cache under `--cache-dir` holds decoded footage — put it somewhere appropriate
88
+ and clear it when done.
89
+
90
+ ## License
91
+
92
+ MIT © theundefined
@@ -0,0 +1,72 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "xmcam-fs"
7
+ version = "0.1.0"
8
+ description = "Mount a Xiongmai / iCSee / XMeye IP camera as a FUSE filesystem and browse its recordings as files (MP4 or raw), sshfs-style. No cloud."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "theundefined", email = "undefine@aramin.net" }]
14
+ keywords = [
15
+ "xiongmai", "xm", "icsee", "xmeye", "dvrip", "onvif", "fuse", "pyfuse3",
16
+ "filesystem", "ip-camera", "cctv", "nvr", "recordings", "mount",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Environment :: Console",
21
+ "Intended Audience :: System Administrators",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Operating System :: POSIX :: Linux",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3 :: Only",
26
+ "Topic :: Multimedia :: Video",
27
+ "Topic :: System :: Filesystems",
28
+ ]
29
+ dependencies = [
30
+ "xmcam-py>=0.1.0",
31
+ "pyfuse3>=3.2.0",
32
+ "trio>=0.22",
33
+ ]
34
+
35
+ [project.optional-dependencies]
36
+ dev = [
37
+ "pytest>=7",
38
+ "ruff>=0.3.0",
39
+ "black>=24.0.0",
40
+ "mypy>=1.9.0",
41
+ "build>=1.0.0",
42
+ "twine>=5.0.0",
43
+ ]
44
+
45
+ [project.urls]
46
+ Homepage = "https://github.com/theundefined/xmcam-fs"
47
+ Repository = "https://github.com/theundefined/xmcam-fs"
48
+ Issues = "https://github.com/theundefined/xmcam-fs/issues"
49
+
50
+ [project.scripts]
51
+ xmcam-fs = "xmcamfs.cli:main"
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["src/xmcamfs"]
55
+
56
+ [tool.pytest.ini_options]
57
+ testpaths = ["tests"]
58
+ addopts = "-q"
59
+
60
+ [tool.black]
61
+ line-length = 120
62
+ target-version = ["py310"]
63
+
64
+ [tool.ruff]
65
+ line-length = 120
66
+ target-version = "py310"
67
+
68
+ [tool.ruff.lint]
69
+ select = ["E4", "E7", "E9", "F"]
70
+
71
+ [tool.mypy]
72
+ ignore_missing_imports = true
@@ -0,0 +1,113 @@
1
+ #!/bin/bash
2
+
3
+ # Exit immediately if a command exits with a non-zero status.
4
+ set -e
5
+
6
+ # Configuration
7
+ PROJECT_NAME="xmcam-fs"
8
+ DEFAULT_INITIAL_VERSION="v0.1.0"
9
+ VERSION_BUMP_TYPE="patch" # Can be major, minor, patch
10
+
11
+ # Function to display usage
12
+ usage() {
13
+ echo "Usage: $0 [major|minor|patch|vX.Y.Z]"
14
+ echo " Automatically bumps the version, creates a git tag, and pushes it."
15
+ echo " If a version is specified (vX.Y.Z), it will use that exact version."
16
+ exit 1
17
+ }
18
+
19
+ # Parse arguments
20
+ if [ "$#" -eq 1 ]; then
21
+ if [[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
22
+ NEW_VERSION_ARG="$1"
23
+ elif [[ "$1" =~ ^(major|minor|patch)$ ]]; then
24
+ VERSION_BUMP_TYPE="$1"
25
+ else
26
+ usage
27
+ fi
28
+ elif [ "$#" -ne 0 ]; then
29
+ usage
30
+ fi
31
+
32
+ # Fetch all tags to ensure we have the latest
33
+ echo "Fetching latest tags..."
34
+ git fetch origin --tags
35
+
36
+ # Get the latest version tag
37
+ LAST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "$DEFAULT_INITIAL_VERSION")
38
+
39
+ # If no semantic version tags exist, start with the default initial version and assume 0.0.0 for first bump
40
+ if [ "$LAST_TAG" = "$DEFAULT_INITIAL_VERSION" ] && [ "$(git tag --list "$DEFAULT_INITIAL_VERSION")" = "" ]; then
41
+ echo "No semantic version tags found. Starting with $DEFAULT_INITIAL_VERSION."
42
+ CURRENT_VERSION_SEMVER="0.0.0" # Base for first bump
43
+ else
44
+ CURRENT_VERSION_SEMVER=$(echo "$LAST_TAG" | sed 's/^v//')
45
+ fi
46
+
47
+ # Determine the new version
48
+ if [ -n "$NEW_VERSION_ARG" ]; then
49
+ NEW_VERSION=$(echo "$NEW_VERSION_ARG" | sed 's/^v//')
50
+ echo "Using specified version: v$NEW_VERSION"
51
+ else
52
+ # Split version into major, minor, patch
53
+ IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION_SEMVER"
54
+
55
+ case "$VERSION_BUMP_TYPE" in
56
+ major)
57
+ MAJOR=$((MAJOR + 1))
58
+ MINOR=0
59
+ PATCH=0
60
+ ;;
61
+ minor)
62
+ MINOR=$((MINOR + 1))
63
+ PATCH=0
64
+ ;;
65
+ patch)
66
+ PATCH=$((PATCH + 1))
67
+ ;;
68
+ *)
69
+ echo "Invalid bump type: $VERSION_BUMP_TYPE. Must be major, minor, or patch."
70
+ usage
71
+ ;;
72
+ esac
73
+ NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
74
+ echo "Bumping $VERSION_BUMP_TYPE version: v$CURRENT_VERSION_SEMVER -> v$NEW_VERSION"
75
+ fi
76
+
77
+ # Update version in pyproject.toml
78
+ sed -i "s/^version = \".*\"/version = \"$NEW_VERSION\"/" pyproject.toml
79
+
80
+ # Create commit for version bump only if there are changes
81
+ git add pyproject.toml
82
+ if ! git diff --cached --quiet; then
83
+ git commit -m "Bump version to v$NEW_VERSION"
84
+ echo "Committed version bump."
85
+ else
86
+ echo "Version in pyproject.toml is already up to date. Skipping commit."
87
+ fi
88
+
89
+ # Create new tag if it doesn't exist
90
+ NEW_TAG="v$NEW_VERSION"
91
+ if git rev-parse "$NEW_TAG" >/dev/null 2>&1; then
92
+ echo "Tag $NEW_TAG already exists."
93
+ else
94
+ echo "Creating git tag: $NEW_TAG"
95
+ git tag "$NEW_TAG" -m "$PROJECT_NAME Release $NEW_VERSION"
96
+ fi
97
+
98
+ # Push commit and tag
99
+ echo "Pushing changes and tag to origin..."
100
+ git push origin main || echo "Main already up to date or push failed (skipping)"
101
+ git push origin "$NEW_TAG"
102
+
103
+ # Get repository owner and name
104
+ REPO_URL=$(git config --get remote.origin.url)
105
+ REPO_OWNER=$(echo "$REPO_URL" | sed -E 's/.*github.com[:/]([^/]+)\/.*/\1/')
106
+ REPO_NAME=$(echo "$REPO_URL" | sed -E 's/.*github.com[:/][^/]+\/([^/\. ]+)(\.git)?/\1/')
107
+ REPO_PATH="$REPO_OWNER/$REPO_NAME"
108
+
109
+ # Construct Actions URL
110
+ ACTIONS_URL="https://github.com/$REPO_PATH/actions/workflows/ci.yml"
111
+ echo "Monitor action status here: $ACTIONS_URL"
112
+
113
+ echo "Release process initiated with tag $NEW_TAG. Check GitHub Actions for build status."
@@ -0,0 +1,14 @@
1
+ """xmcam-fs - mount a Xiongmai / iCSee / XMeye camera as a FUSE filesystem.
2
+
3
+ Browse a camera's recording archive as ordinary files, sshfs-style, backed by
4
+ the :mod:`xmcam` library. Each recording appears twice:
5
+
6
+ * under ``mp4/`` as a playable MP4 (produced lazily on first open and cached), and
7
+ * under ``raw/`` as the original Xiongmai ``.xmraw`` container.
8
+
9
+ The filesystem is read-only and talks only to the local network - no cloud.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ __version__ = "0.1.0"
@@ -0,0 +1,113 @@
1
+ """On-disk cache of camera segments: download once, serve from disk.
2
+
3
+ A segment is slow to fetch (DVRIP streams at roughly realtime/2), so each one is
4
+ produced exactly once and then read straight off disk. Because a Xiongmai camera
5
+ has a single playback channel, downloads are serialised with one lock -- running
6
+ two ``OPPlayBack`` sessions at once provokes ``Ret 103``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ from pathlib import Path
13
+
14
+ from xmcam.dvrip import DVRIPClient
15
+ from xmcam.exceptions import XMCamError
16
+ from xmcam.media import convert_raw, have_ffmpeg
17
+ from xmcam.models import Credentials, Recording
18
+ from xmcam.playback import download_recording
19
+
20
+
21
+ def _safe_stem(rec: Recording) -> str:
22
+ """A flat, collision-free filename stem derived from the recording path.
23
+
24
+ The camera path already contains the date and a unique ``[@token]``, so
25
+ flattening it yields a stable, unique name.
26
+ """
27
+ stem = (rec.filename or "recording").strip("/").replace("/", "_")
28
+ for ch in "[]":
29
+ stem = stem.replace(ch, "")
30
+ for ext in (".h264", ".h265", ".jpg"):
31
+ if stem.lower().endswith(ext):
32
+ stem = stem[: -len(ext)]
33
+ break
34
+ return stem or "recording"
35
+
36
+
37
+ class SegmentCache:
38
+ """Produce and cache the ``mp4`` / ``raw`` rendering of a recording."""
39
+
40
+ def __init__(
41
+ self,
42
+ host: str,
43
+ credentials: Credentials,
44
+ cache_dir,
45
+ port: int = 34567,
46
+ channel: int = 0,
47
+ stream: str = "Main",
48
+ ) -> None:
49
+ self.host = host
50
+ self.creds = credentials
51
+ self.cache_dir = Path(cache_dir)
52
+ self.port = port
53
+ self.channel = channel
54
+ self.stream = stream
55
+ self._download_lock = threading.Lock() # one playback channel per camera
56
+
57
+ for sub in ("raw", "mp4", ".tmp"):
58
+ (self.cache_dir / sub).mkdir(parents=True, exist_ok=True)
59
+
60
+ # -- public ---------------------------------------------------------------
61
+ def path_for(self, rec: Recording, view: str) -> Path:
62
+ """The cache path for this (recording, view), whether or not it exists yet."""
63
+ stem = _safe_stem(rec)
64
+ if view.lower() == "raw":
65
+ return self.cache_dir / "raw" / f"{stem}.xmraw"
66
+ return self.cache_dir / "mp4" / f"{stem}.mp4"
67
+
68
+ def ensure(self, rec: Recording, view: str) -> Path:
69
+ """Return the cache path, downloading/converting once if needed (BLOCKING)."""
70
+ dest = self.path_for(rec, view)
71
+ if dest.exists() and dest.stat().st_size > 0:
72
+ return dest
73
+
74
+ with self._download_lock:
75
+ # Re-check: another thread may have produced it while we waited.
76
+ if dest.exists() and dest.stat().st_size > 0:
77
+ return dest
78
+ if view.lower() == "raw":
79
+ self._download_raw(rec, dest)
80
+ else:
81
+ self._produce_mp4(rec, dest)
82
+ return dest
83
+
84
+ # -- internals ------------------------------------------------------------
85
+ def _client(self) -> DVRIPClient:
86
+ cam = DVRIPClient(self.host, user=self.creds.username, password=self.creds.password, port=self.port)
87
+ cam.login_or_raise()
88
+ return cam
89
+
90
+ def _download_raw(self, rec: Recording, dest: Path) -> None:
91
+ tmp = dest.with_name(dest.name + ".part")
92
+ cam = self._client()
93
+ try:
94
+ download_recording(cam, rec, tmp, channel=self.channel, stream=self.stream)
95
+ finally:
96
+ cam.close()
97
+ tmp.replace(dest)
98
+
99
+ def _produce_mp4(self, rec: Recording, dest: Path) -> None:
100
+ if not have_ffmpeg():
101
+ raise XMCamError("ffmpeg not found; cannot produce the MP4 view")
102
+ stem = _safe_stem(rec)
103
+ raw_tmp = self.cache_dir / ".tmp" / (stem + ".xmraw")
104
+ self._download_raw(rec, raw_tmp)
105
+ # The temp output must keep a real ``.mp4`` extension so ffmpeg can infer
106
+ # the muxer (a ``.part`` suffix makes it fail with "no output format").
107
+ tmp_mp4 = self.cache_dir / ".tmp" / (stem + ".partial.mp4")
108
+ ok = convert_raw(raw_tmp, tmp_mp4, keep_raw=False)
109
+ if not ok or not tmp_mp4.exists():
110
+ if tmp_mp4.exists():
111
+ tmp_mp4.unlink()
112
+ raise XMCamError(f"MP4 conversion failed for {rec.filename}")
113
+ tmp_mp4.replace(dest)