aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-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
16 files changed, 447 insertions, 12 deletions
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()