From 1521cc1ac0cca7ee8786056f84fa399d5cc268f9 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 10 Oct 2026 15:26:45 +0200 Subject: 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 --- .../meshbay-node/src/meshbay_node/enrichment.py | 16 ++++- .../meshbay-node/tests/test_photo_folder_videos.py | 80 ++++++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 packages/meshbay-node/tests/test_photo_folder_videos.py (limited to 'packages/meshbay-node') 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() -- cgit v1.2.3