aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md12
-rw-r--r--docs/USERGUIDE.md8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js67
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css23
-rwxr-xr-xpackages/meshbay-hub/tests/harness/photo_video_probe.py170
-rw-r--r--packages/meshbay-hub/tests/test_photo_videos.py51
-rw-r--r--packages/meshbay-node/src/meshbay_node/enrichment.py16
-rw-r--r--packages/meshbay-node/tests/test_photo_folder_videos.py80
18 files changed, 464 insertions, 15 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 5ba1980..4c376ea 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -3136,6 +3136,16 @@ An album browser over the image files in the group's configured folders. It is
An **album is a directory**, exactly as a season is a folder in Videos. Thumbnails
are node-side, orientation-corrected, and delivered through the same chunk path.
+**A video in a photo folder belongs to its album**: a phone's clips, backed up
+beside its photos (§9.12), are where they were on the phone. The node probes
+and thumbnails them with the video enricher (duration and a frame, nothing
+looked up anywhere: matching is the Videos app's, asked by its own pages).
+The album card counts photos and videos apart, a clip's tile carries a play
+mark and its length, and the lightbox shows its thumbnail with a play button
+that hands it to the group's video player, so a clip of a gigabyte is streamed
+like a film and never downloaded whole into the lightbox. A slideshow is of
+photos: it passes a clip by. The television follows photos only.
+
**EXIF is read locally on the node** and narrowed on purpose: a capture time and a
camera, and **never GPS**, anywhere, in any field a client receives. The claim this
supports is precise, and the one it must not make matters more:
@@ -3423,7 +3433,7 @@ most of it. A file is never whole in the page: the upload reads it a chunk at
a time and each chunk is a ranged request to the phone (`Range: bytes=a-b`,
answered 206 by `ByteRange.kt`), so a video of gigabytes costs the WebView one
chunk of memory; the SHA-256 the ledger keeps is then read from the phone once
-the node has the file. The Photos tab does not show videos yet; Files does.
+the node has the file. The Photos tab shows them in their albums (§9.9).
**Location.** The manifest does not ask for `ACCESS_MEDIA_LOCATION`, so a
photo or video read through MediaStore has its location **redacted by the
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
index dac1f9f..7434be2 100644
--- a/docs/USERGUIDE.md
+++ b/docs/USERGUIDE.md
@@ -372,6 +372,10 @@ is a folder**. Thumbnails come from the node, already rotated correctly. A
photo opens full size, with a slideshow button that moves on every five
seconds and stops at the album's last photo.
+Videos in an album's folder, such as a phone's clips, show among its photos
+with a play mark and their length; open one and press play to watch it. A
+slideshow skips them.
+
A photo you uploaded can be deleted: right-click it, or use the bin in the bar
above an open photo (the way in on a phone). The node's operator can delete any
photo. Both ask first.
@@ -410,8 +414,8 @@ backup at all.
camera is ticked; screenshots and other albums are yours to add.
- **Videos too** adds the videos of the same albums, beside the photos. It is
off by default: videos are large, and the first backup can take a while.
- They are sent on Wi-Fi like photos. For now they show in Files, not in the
- Photos tab.
+ They are sent on Wi-Fi like photos, and show in their albums in the Photos
+ tab.
- By default **the photos already on the phone are sent too**, newest first,
then each new one. Choose "Only photos taken from now on" to skip the ones
already there. Before the first send you are told how many photos and how
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index ca15345..38cadef 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -380,6 +380,11 @@ export default {
one: '{n} Foto',
other: '{n} Fotos',
},
+ 'photo.n_videos': {
+ one: "{n} Video",
+ other: "{n} Videos",
+ },
+ 'photo.play_video': "Video abspielen",
'photo.back': 'Zurück',
'photo.prev': 'Vorherige (←)',
'photo.next': 'Nächste (→)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 8619bdb..9dff21d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -378,6 +378,11 @@ export default {
one: '{n} photo',
other: '{n} photos',
},
+ 'photo.n_videos': {
+ one: "{n} video",
+ other: "{n} videos",
+ },
+ 'photo.play_video': "Play the video",
'photo.back': 'Back',
'photo.prev': 'Previous (←)',
'photo.next': 'Next (→)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 19db8f7..6f4252a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -378,6 +378,11 @@ export default {
one: '{n} foto',
other: '{n} fotos',
},
+ 'photo.n_videos': {
+ one: "{n} vídeo",
+ other: "{n} vídeos",
+ },
+ 'photo.play_video': "Reproducir el vídeo",
'photo.back': 'Atrás',
'photo.prev': 'Anterior (←)',
'photo.next': 'Siguiente (→)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 3f3cd42..65956ae 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -379,6 +379,11 @@ export default {
one: '{n} photo',
other: '{n} photos',
},
+ 'photo.n_videos': {
+ one: "{n} vidéo",
+ other: "{n} vidéos",
+ },
+ 'photo.play_video': "Lire la vidéo",
'photo.back': 'Retour',
'photo.prev': 'Précédent (←)',
'photo.next': 'Suivant (→)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index f238495..a37d65c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -379,6 +379,11 @@ export default {
one: '{n} foto',
other: '{n} foto',
},
+ 'photo.n_videos': {
+ one: "{n} video",
+ other: "{n} video",
+ },
+ 'photo.play_video': "Riproduci il video",
'photo.back': 'Indietro',
'photo.prev': 'Precedente (←)',
'photo.next': 'Successiva (→)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 6a59d1d..4910630 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -376,6 +376,11 @@ export default {
one: '{n}枚',
other: '{n}枚',
},
+ 'photo.n_videos': {
+ one: "動画 {n} 本",
+ other: "動画 {n} 本",
+ },
+ 'photo.play_video': "動画を再生",
'photo.back': '戻る',
'photo.prev': '前へ(←)',
'photo.next': '次へ(→)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 4b68975..603f69d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -380,6 +380,11 @@ export default {
one: '{n} foto',
other: "{n} foto's",
},
+ 'photo.n_videos': {
+ one: "{n} video",
+ other: "{n} video's",
+ },
+ 'photo.play_video': "Video afspelen",
'photo.back': 'Terug',
'photo.prev': 'Vorige (←)',
'photo.next': 'Volgende (→)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 8b1b5db..52b6a25 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -391,6 +391,13 @@ export default {
many: '{n} zdjęć',
other: '{n} zdjęcia',
},
+ 'photo.n_videos': {
+ one: "{n} film",
+ few: "{n} filmy",
+ many: "{n} filmów",
+ other: "{n} filmu",
+ },
+ 'photo.play_video': "Odtwórz film",
'photo.back': 'Wstecz',
'photo.prev': 'Poprzednie (←)',
'photo.next': 'Następne (→)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 9fc634b..0371dfe 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -380,6 +380,11 @@ export default {
one: '{n} foto',
other: '{n} fotos',
},
+ 'photo.n_videos': {
+ one: "{n} vídeo",
+ other: "{n} vídeos",
+ },
+ 'photo.play_video': "Reproduzir o vídeo",
'photo.back': 'Voltar',
'photo.prev': 'Anterior (←)',
'photo.next': 'Próxima (→)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 5859934..7dd09bf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -373,6 +373,11 @@ export default {
one: '{n} 张照片',
other: '{n} 张照片',
},
+ 'photo.n_videos': {
+ one: "{n} 个视频",
+ other: "{n} 个视频",
+ },
+ 'photo.play_video': "播放视频",
'photo.back': '返回',
'photo.prev': '上一张(←)',
'photo.next': '下一张(→)',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
index f18acd2..df207ab 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
@@ -36,7 +36,9 @@ function underAnyPhotoRoot(entry, photoRoots) {
function groupPhotoAlbums(entries, photoRoots) {
const byDir = new Map();
for (const e of entries) {
- if (e.type !== 'image' || !underAnyPhotoRoot(e, photoRoots)) continue;
+ // A video in a photo folder is a phone's clip, backed up beside its
+ // photos (§9.12): it belongs to the album as it did on the phone.
+ if ((e.type !== 'image' && e.type !== 'video') || !underAnyPhotoRoot(e, photoRoots)) continue;
// e.path is already the file's containing directory, not the full
// path+filename (files-app.js's own convention, also relied on by
// zipstream.js's entriesUnder) — it must not be stripped a second time,
@@ -85,6 +87,25 @@ function albumYearLabel(photos) {
// ── landing grid: one card per album (directory containing images) ─────────
+/** A clip's length as a phone shows it: `0:42`, `1:02:05`. */
+function clipLength(seconds) {
+ if (!seconds) return '';
+ const total = Math.round(seconds);
+ const h = Math.floor(total / 3600);
+ const m = Math.floor((total % 3600) / 60);
+ const sec = String(total % 60).padStart(2, '0');
+ return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`;
+}
+
+/** "12 photos", "12 photos · 3 videos", or "3 videos". */
+function albumCount(items) {
+ const videos = items.filter((e) => e.type === 'video').length;
+ const photos = items.length - videos;
+ if (!videos) return t('photo.n_photos', { n: photos });
+ const clips = t('photo.n_videos', { n: videos });
+ return photos ? `${t('photo.n_photos', { n: photos })} · ${clips}` : clips;
+}
+
function AlbumCard({ album, transportRef, gekRef, onOpen }) {
const cover = album.photos.find((p) => p.thumb_hash) || album.photos[0];
const tRef = cover._tRef || transportRef;
@@ -98,7 +119,7 @@ function AlbumCard({ album, transportRef, gekRef, onOpen }) {
<div class="photo-album-info">
<div class="photo-album-title">${albumTitle(album.dir)}</div>
<div class="photo-album-sub">
- ${year}${year ? ' · ' : ''}${t('photo.n_photos', { n: album.photos.length })}
+ ${year}${year ? ' · ' : ''}${albumCount(album.photos)}
</div>
<${SourceTag} entries=${album.photos} cls="photo-card-group" />
</div>
@@ -128,7 +149,9 @@ function PhotoTile({ entry, transportRef, gekRef, onOpen, onMenu }) {
<div class="photo-tile" onClick=${onOpen} onContextMenu=${onMenu}>
<${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.name}
cls="photo-tile-thumb" transportRef=${tRef} gekRef=${gRef}
- emptyIcon="image" />
+ emptyIcon=${entry.type === 'video' ? 'video' : 'image'} />
+ ${entry.type === 'video' && html`
+ <span class="photo-tile-video"><${Icon} name="play" />${clipLength(entry.duration)}</span>`}
</div>
`;
}
@@ -156,8 +179,13 @@ const ZOOM_MAX = 400;
// a slow node must not skip a photo nobody saw.
const SLIDESHOW_MS = 5000;
-function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor, onDelete }) {
+function Lightbox({
+ photos, index, transportRef, gekRef, onClose, onNav, linkFor, onDelete, onPlay,
+}) {
const entry = photos[index];
+ // A video is shown by its thumbnail and played in the group's video
+ // player: never downloaded whole into this page.
+ const isVideo = entry.type === 'video';
const tRef = entry._tRef || transportRef;
const gRef = entry._gRef || gekRef;
const [blobUrl, setBlobUrl] = useState(() => _fullBlobCache.get(entry.id) || null);
@@ -174,8 +202,9 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
const castDevice = useCastDevice();
useEffect(() => {
+ if (isVideo) { setBlobUrl(null); setLoading(false); return undefined; }
const cached = _fullBlobCache.get(entry.id);
- if (cached) { setBlobUrl(cached); setLoading(false); return; }
+ if (cached) { setBlobUrl(cached); setLoading(false); return undefined; }
setBlobUrl(null);
setLoading(true);
let cancelled = false;
@@ -211,6 +240,14 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
if (castDevice && blobUrl && blob) showPhoto(blob);
}, [castDevice, blobUrl, entry.id]);
+ // A slideshow is of photos: it passes a video by rather than wait on a
+ // clip nobody started.
+ useEffect(() => {
+ if (!slideshow || !isVideo) return;
+ if (index < photos.length - 1) onNav(1);
+ else setSlideshow(false);
+ }, [slideshow, isVideo, index, photos.length, onNav]);
+
// Next photo once this one has been on screen for SLIDESHOW_MS; the last
// photo ends the slideshow rather than wrapping round.
useEffect(() => {
@@ -224,6 +261,8 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
useEffect(() => {
const onKey = (e) => {
+ // The video player is over this, and its keys are its own.
+ if (document.querySelector('.video-player-overlay')) return;
if (e.key === 'Escape') onClose();
else if (e.key === 'ArrowLeft' && index > 0) onNav(-1);
else if (e.key === 'ArrowRight' && index < photos.length - 1) onNav(1);
@@ -304,6 +343,15 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
<${Icon} name="chevron" cls="photo-nav-icon photo-nav-prev-icon" /></button>
<div ref=${slotRef} class="photo-lightbox-image-slot ${zoomed ? 'zoomed' : ''}">
${loading && html`<span class="spinner"></span>`}
+ ${isVideo && html`
+ <div class="photo-lightbox-video">
+ <${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.name}
+ cls="photo-lightbox-poster" transportRef=${tRef} gekRef=${gRef} emptyIcon="video" />
+ ${onPlay && html`
+ <button class="photo-lightbox-play" onClick=${() => onPlay(entry)}
+ title=${t('photo.play_video')} aria-label=${t('photo.play_video')}>
+ <${Icon} name="play" /></button>`}
+ </div>`}
${blobUrl && html`<img class="photo-lightbox-image ${zoomed ? 'zoomed' : ''}"
style=${imgStyle} src=${blobUrl} alt=${entry.name} onLoad=${handleImgLoad} />`}
</div>
@@ -313,6 +361,7 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
</div>
<div class="photo-lightbox-info">
${entry.width && entry.height && html`<span>${entry.width}×${entry.height}</span>`}
+ ${isVideo && entry.duration && html`<span>${clipLength(entry.duration)}</span>`}
<span>${formatSize(entry.size)}</span>
${entry.taken_at && html`<span>${formatTakenAt(entry.taken_at)}</span>`}
${entry.camera && html`<span>${entry.camera}</span>`}
@@ -323,7 +372,7 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
}
function AlbumView({
- album, entries, transportRef, gekRef, setError, onBack, readOnly, linkFor, onDelete,
+ album, entries, transportRef, gekRef, setError, onBack, readOnly, linkFor, onDelete, onPlay,
}) {
const [lightboxIndex, setLightboxIndex] = useState(null);
// Right-click a photo for its link, or to delete it. A tile has no dots to
@@ -393,7 +442,7 @@ function AlbumView({
<${Lightbox} photos=${album.photos} index=${lightboxIndex}
transportRef=${transportRef} gekRef=${gekRef}
onClose=${() => setLightboxIndex(null)} onNav=${navigate} linkFor=${linkFor}
- onDelete=${onDelete} />
+ onDelete=${onDelete} onPlay=${onPlay} />
`}
${menu && html`<${Menu} ...${menu} onClose=${closeMenu} />`}
`;
@@ -405,7 +454,7 @@ function PhotosApp({
groupId, transportRef, gekRef, status, entries, availableEntries,
photoDirectories, setError,
hideFilter, readOnly, linkFor,
- isNodeAdmin, userId, applyIndex,
+ isNodeAdmin, userId, applyIndex, onPreview,
}) {
const [openDir, setOpenDir] = useState(null);
const [filter, setFilter] = useState('');
@@ -479,7 +528,7 @@ function PhotosApp({
<${AlbumView} album=${openAlbum} entries=${entries}
transportRef=${transportRef} gekRef=${gekRef} setError=${setError}
onBack=${() => setOpenDir(null)} readOnly=${readOnly} linkFor=${linkFor}
- onDelete=${deleteFor} />
+ onDelete=${deleteFor} onPlay=${readOnly ? null : onPreview} />
`}
`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index ea017fb..1b8dd8e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -5104,6 +5104,29 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
border: 1px solid var(--border);
}
.photo-tile:hover { border-color: var(--accent); }
+.photo-tile { position: relative; }
+/* A video among the photos: a play mark and its length over the thumbnail. */
+.photo-tile-video {
+ position: absolute; left: 6px; bottom: 6px;
+ display: inline-flex; align-items: center; gap: 3px;
+ padding: 2px 6px; border-radius: 10px;
+ background: rgba(0, 0, 0, 0.6); color: #fff;
+ font-size: 0.75em; font-variant-numeric: tabular-nums;
+ pointer-events: none;
+}
+.photo-tile-video .icon { width: 0.9em; height: 0.9em; }
+.photo-lightbox-video {
+ position: relative; display: flex; align-items: center; justify-content: center;
+ max-width: 100%; max-height: 100%;
+}
+.photo-lightbox-poster { max-width: 100%; max-height: 70vh; object-fit: contain; display: block; }
+.photo-lightbox-play {
+ position: absolute; width: 64px; height: 64px; border-radius: 50%;
+ border: 0; background: rgba(0, 0, 0, 0.6); color: #fff; cursor: pointer;
+ display: flex; align-items: center; justify-content: center;
+}
+.photo-lightbox-play:hover { background: var(--accent); }
+.photo-lightbox-play .icon { width: 28px; height: 28px; }
.photo-tile-thumb {
width: 100%;
aspect-ratio: 1 / 1;
diff --git a/packages/meshbay-hub/tests/harness/photo_video_probe.py b/packages/meshbay-hub/tests/harness/photo_video_probe.py
new file mode 100755
index 0000000..02ebb90
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/photo_video_probe.py
@@ -0,0 +1,170 @@
+#!/usr/bin/env python3
+"""
+Videos among the photos of an album, in a real browser.
+
+Mounts the shipped `PhotosApp` on a made-up index of two photos and a phone's
+clip in one folder, with an `onPreview` that records what it was asked to
+play. Reads the album card, the clip's tile, the lightbox on the clip, and a
+slideshow started on it.
+
+ photo_video_probe.py
+
+Prints JSON: one entry per case.
+"""
+
+import http.server
+import json
+import socketserver
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
+PORT = 8777
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+
+FRAME = r"""<!doctype html><html><head><meta charset=utf-8>
+<link rel="stylesheet" href="/style.css"></head><body>
+<div id="photos"></div>
+<script type="module">
+import { html, render } from '/vendor/htm-preact.js';
+import { initLocale, setLocale } from '/i18n.js';
+import { PhotosApp } from '/photos-app.js';
+
+const LOGS = [];
+addEventListener('error', (e) => LOGS.push('error: ' + (e.message || e)));
+addEventListener('unhandledrejection',
+ (e) => LOGS.push('rejection: ' + (e.reason && e.reason.message || e.reason)));
+const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+
+const ENTRIES = [
+ { id: 'p1', name: 'a.jpg', path: 'Pics/2026-10', size: 4, type: 'image', added_at: 1 },
+ { id: 'v1', name: 'b.mp4', path: 'Pics/2026-10', size: 9e8, type: 'video', added_at: 2,
+ duration: 42 },
+ { id: 'p2', name: 'c.jpg', path: 'Pics/2026-10', size: 4, type: 'image', added_at: 3 },
+];
+const PLAYED = [];
+const ref = { current: null };
+
+(async () => {
+ const cases = [];
+ try {
+ setLocale('en');
+ await initLocale();
+ render(html`<${PhotosApp} groupId="g" transportRef=${ref} gekRef=${ref}
+ status="connected" entries=${ENTRIES} photoDirectories=${['Pics']}
+ setError=${() => {}} linkFor=${() => null}
+ onPreview=${(e) => PLAYED.push(e.id)} />`, document.getElementById('photos'));
+ await frame();
+ const card = document.querySelector('.photo-album-card');
+ cases.push({ case: 'card', text: card ? card.textContent : null });
+ card.click(); await frame();
+
+ const tiles = [...document.querySelectorAll('.photo-tile')];
+ cases.push({ case: 'tiles', count: tiles.length,
+ badges: tiles.map((t) => (t.querySelector('.photo-tile-video') || {}).textContent || null) });
+
+ tiles[1].click(); await frame();
+ const title = () => (document.querySelector('.photo-lightbox .video-title') || {}).textContent;
+ const play = document.querySelector('.photo-lightbox-play');
+ const onClip = title();
+ if (play) { play.click(); await frame(); }
+ cases.push({ case: 'lightbox', title: onClip, play: Boolean(play), played: PLAYED.slice(),
+ info: (document.querySelector('.photo-lightbox-info') || {}).textContent });
+
+ const show = [...document.querySelectorAll('.photo-lightbox .video-close')]
+ .find((b) => (b.title || '').toLowerCase().includes('slideshow'));
+ if (show) show.click();
+ await wait(300); await frame();
+ cases.push({ case: 'slideshow', button: Boolean(show), now: title() });
+
+ parent.postMessage({ cases, logs: LOGS, origin: location.origin }, '*');
+ } catch (err) {
+ parent.postMessage({ error: String(err && (err.stack || err)), logs: LOGS }, '*');
+ }
+})();
+</script></body></html>"""
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head>
+<body style="margin:0"><div id="frames"></div><script>
+addEventListener('message', (e) => {
+ fetch('/log', { method: 'POST', body: JSON.stringify(e.data) });
+});
+const f = document.createElement('iframe');
+f.src = '/case';
+f.style.cssText = 'width:1100px;height:800px;border:0;display:block';
+document.getElementById('frames').appendChild(f);
+</script></body></html>"""
+
+
+class H(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_POST(self):
+ length = int(self.headers.get("Content-Length") or 0)
+ if self.path == "/log":
+ RECORDS.append(json.loads(self.rfile.read(length).decode()))
+ else:
+ self.rfile.read(length)
+ self.send_response(204)
+ self.end_headers()
+
+ def _send(self, body: bytes, ctype: str) -> None:
+ self.send_response(200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self):
+ path = self.path.split("?")[0]
+ if path == "/":
+ self._send(PAGE.encode(), "text/html; charset=utf-8")
+ elif path == "/case":
+ self._send(FRAME.encode(), "text/html; charset=utf-8")
+ else:
+ asset = (STATIC / path.lstrip("/")).resolve()
+ if not str(asset).startswith(str(STATIC)) or not asset.is_file():
+ self.send_response(404)
+ self.end_headers()
+ return
+ self._send(asset.read_bytes(),
+ "text/css" if asset.suffix == ".css"
+ else "text/javascript" if asset.suffix == ".js"
+ else "application/octet-stream")
+
+
+def main() -> int:
+ with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile:
+ proc = subprocess.Popen(
+ ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
+ f"--user-data-dir={profile}", "--window-size=1100,900",
+ f"http://127.0.0.1:{PORT}/"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ for _ in range(300):
+ if RECORDS:
+ break
+ time.sleep(0.1)
+ proc.terminate()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ if not RECORDS:
+ print(json.dumps({"error": "no measurement"}), file=sys.stderr)
+ return 1
+ print(json.dumps(RECORDS[0], indent=1))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/packages/meshbay-hub/tests/test_photo_videos.py b/packages/meshbay-hub/tests/test_photo_videos.py
new file mode 100644
index 0000000..2585f47
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_photo_videos.py
@@ -0,0 +1,51 @@
+"""
+A phone's clips among the photos of an album (harness/photo_video_probe.py).
+
+Backed up beside the photos (docs/MESHBAY_DESIGN.md §9.12), a clip belongs to
+its album: counted on the card, marked and timed on its tile, and played in
+the group's video player from the lightbox, never downloaded whole into it. A
+slideshow is of photos and passes a clip by.
+"""
+
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "photo_video_probe.py"
+
+
+@pytest.fixture(scope="module")
+def cases():
+ if shutil.which("google-chrome") is None:
+ pytest.skip("Chrome is not available")
+ proc = subprocess.run([sys.executable, str(HARNESS)],
+ capture_output=True, text=True, timeout=180)
+ assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
+ out = json.loads(proc.stdout)
+ assert "error" not in out, out["error"]
+ assert out["logs"] == []
+ return {c["case"]: c for c in out["cases"]}
+
+
+def test_the_album_counts_photos_and_videos_apart(cases):
+ assert "2 photos · 1 video" in cases["card"]["text"]
+
+
+def test_a_clip_is_in_the_album_marked_with_its_length(cases):
+ assert cases["tiles"]["count"] == 3
+ assert cases["tiles"]["badges"] == [None, "0:42", None]
+
+
+def test_the_lightbox_hands_a_clip_to_the_video_player(cases):
+ c = cases["lightbox"]
+ assert c["title"] == "b.mp4" and c["play"]
+ assert c["played"] == ["v1"]
+ assert "0:42" in c["info"]
+
+
+def test_a_slideshow_passes_a_clip_by(cases):
+ assert cases["slideshow"]["button"] and cases["slideshow"]["now"] == "c.jpg"
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()