aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-10 15:26:45 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-10 15:26:45 +0200
commit1521cc1ac0cca7ee8786056f84fa399d5cc268f9 (patch)
tree00cc3132ee05894350db12e8e5cb94f81b1d4876 /packages/meshbay-node
parent1fd7bbcd038cddf40540bae7083b9adf4c728912 (diff)
downloadmeshbay-1521cc1ac0cca7ee8786056f84fa399d5cc268f9.tar.gz
feat(photos): show a phone's clips in their albums
Videos in a photo folder are thumbnailed by the node, counted and marked in the album, and played in the group's video player from the lightbox; the slideshow passes them by. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/enrichment.py16
-rw-r--r--packages/meshbay-node/tests/test_photo_folder_videos.py80
2 files changed, 93 insertions, 3 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/enrichment.py b/packages/meshbay-node/src/meshbay_node/enrichment.py
index 66b69b9..19aafba 100644
--- a/packages/meshbay-node/src/meshbay_node/enrichment.py
+++ b/packages/meshbay-node/src/meshbay_node/enrichment.py
@@ -217,6 +217,11 @@ class EnrichmentMixin:
actually photo albums would burn CPU on files never meant to be in
the Photos app at all. `_enriched_attempted` is shared with the
video/audio paths — content-addressed ids never collide across them.
+
+ A video in a photo folder (a phone's clips, backed up beside its
+ photos, §9.12) is probed and thumbnailed by the video enricher, which
+ is all an album tile and the lightbox need. Nothing there looks it up
+ anywhere: matching is the Videos app's, asked by its own pages.
"""
if not self._photo_enricher or not self._roster:
return
@@ -224,7 +229,9 @@ class EnrichmentMixin:
if not photo_dirs:
return
for entry in entries:
- if entry.type != "image" or (indexer.group_id, entry.id) in self._enriched_attempted:
+ if entry.type not in ("image", "video") or (indexer.group_id, entry.id) in self._enriched_attempted:
+ continue
+ if entry.type == "video" and not self._enricher:
continue
if not _under_any_directory(entry.path, photo_dirs):
continue
@@ -236,7 +243,10 @@ class EnrichmentMixin:
async def on_done(file_id: str, fields: dict, _indexer=indexer) -> None:
await self._on_enriched(_indexer, file_id, fields)
- self._photo_enricher.spawn(entry, file_path, on_done)
+ if entry.type == "video":
+ self._enricher.spawn(entry, file_path, on_done)
+ else:
+ self._photo_enricher.spawn(entry, file_path, on_done)
async def _enrich_photo_roots_now(self, group_id: str) -> None:
"""
@@ -269,7 +279,7 @@ class EnrichmentMixin:
unchanged file is redundant work, not a correctness issue.
"""
for entry in updates:
- if entry.type != "image":
+ if entry.type not in ("image", "video"):
continue
old = previous.get_entry(entry.id)
if old is None or (old.name == entry.name and old.path == entry.path):
diff --git a/packages/meshbay-node/tests/test_photo_folder_videos.py b/packages/meshbay-node/tests/test_photo_folder_videos.py
new file mode 100644
index 0000000..79953a1
--- /dev/null
+++ b/packages/meshbay-node/tests/test_photo_folder_videos.py
@@ -0,0 +1,80 @@
+"""
+A video in a photo folder (a phone's clips, backed up beside its photos,
+docs/MESHBAY_DESIGN.md §9.12) is probed and thumbnailed by the video enricher,
+so the Photos app can show it in its album; one outside every photo folder
+and every video folder is left alone, as before.
+"""
+
+import asyncio
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_common.crypto import generate_gek
+from meshbay_node.config import Config, GroupConfig, HubConfig, KeystoreConfig, NodeConfig
+from meshbay_node.daemon import NodeDaemon
+from meshbay_node.indexer import DirectoryIndexer
+from meshbay_node.indexer.enrich import Enricher
+from meshbay_node.indexer.enrich_photo import PhotoEnricher
+from meshbay_node.media_cache import MediaCache
+from meshbay_node.roster import Roster
+
+from conftest import one_root
+
+pytestmark = pytest.mark.asyncio
+
+
+def _free_port() -> int:
+ import socket
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+async def _make_daemon(tmp_path, shared, group_id):
+ config = Config(
+ hub=HubConfig(url="http://localhost:9999", username="testuser"),
+ node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()),
+ groups=[GroupConfig(
+ id=group_id, name="test-group", shared_dir=str(shared),
+ visibility="private", quic_port=29015,
+ )],
+ keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
+ data_dir=tmp_path / "data",
+ )
+ daemon = NodeDaemon(config)
+ daemon._broadcast_coalesce_secs = 0.01
+ daemon._media_cache = MediaCache(db_path=tmp_path / "media_cache.db")
+ await daemon._media_cache.open()
+ daemon._enricher = Enricher(daemon._media_cache)
+ daemon._photo_enricher = PhotoEnricher(daemon._media_cache)
+ daemon._roster = Roster(db_path=tmp_path / "roster.db")
+ await daemon._roster.open()
+ return daemon
+
+
+async def test_a_video_in_a_photo_folder_goes_to_the_video_enricher(tmp_path):
+ group_id = "b" * 32
+ shared = tmp_path / "shared"
+ (shared / "Photos" / "bob-photos").mkdir(parents=True)
+ (shared / "Photos" / "bob-photos" / "clip.mp4").write_bytes(b"a clip")
+ (shared / "Photos" / "bob-photos" / "shot.jpg").write_bytes(b"a photo")
+ (shared / "elsewhere.mp4").write_bytes(b"not in any app folder")
+
+ daemon = await _make_daemon(tmp_path, shared, group_id)
+ video_spawned, photo_spawned = [], []
+ daemon._enricher.spawn = lambda entry, path, on_done: video_spawned.append(entry.name)
+ daemon._photo_enricher.spawn = lambda entry, path, on_done: photo_spawned.append(entry.name)
+ try:
+ await daemon._roster.set_app_directories(group_id, "photo", ["shared/Photos"], set_by="op")
+ indexer = DirectoryIndexer(
+ roots=one_root(shared), group_id=group_id,
+ sk_node=Ed25519PrivateKey.generate(), gek=generate_gek())
+ await indexer.initial_scan()
+ await daemon._on_index_change(indexer)
+ await asyncio.sleep(0.05)
+
+ assert video_spawned == ["clip.mp4"]
+ assert photo_spawned == ["shot.jpg"]
+ finally:
+ await daemon._media_cache.close()
+ await daemon._roster.close()