1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
|
"""What the node does with a file once it is indexed, per app: the TMDB match and
thumbnail for Videos, tags and cover for Music, thumbnail and EXIF for Photos.
NodeDaemon runs it; the enrichers themselves are in indexer/."""
from dataclasses import replace
from meshbay_node.indexer import DirectoryIndexer, GroupIndex
from meshbay_node.roots import entry_abs_path
def _under_any_directory(path: str, directories: list[str]) -> bool:
"""
Whether an entry's folder is one of an app's directories, or inside one.
Mirrors `underAnyDirectory` in the SPA's app modules. One helper for every
app since they all take a list: Videos and Music used to take a single
folder and had a function each saying the same thing, which is how the two
came to differ in what they did with a trailing slash.
"""
path = path or ""
return any(path == d or path.startswith(d + "/") for d in directories)
def _owning_directory(path: str, directories: list[str]) -> str | None:
"""
Which of an app's directories an entry belongs to — the deepest match.
Deepest, because directories may nest: with both `Media` and
`Media/Albums` configured, a file under the second belongs to the second.
Taking the first match instead would measure it against a boundary one
level too shallow, which for Music is the difference between reading a
folder as an artist and reading it as a release.
"""
path = path or ""
best: str | None = None
for d in directories:
if path == d or path.startswith(d + "/"):
if best is None or len(d) > len(best):
best = d
return best
class EnrichmentMixin:
async def _enrich_new_video_entries(self, indexer: DirectoryIndexer, entries: list) -> None:
"""
Videos app: fire (never await further) enrichment for unattempted
video entries under the group's configured video_root.
A group with no video_root set yet does not enrich anything — TMDB
lookups and ffmpeg thumbnailing are real, ongoing per-file cost
(docs/MESHBAY_DESIGN.md §6.5), and running them over an operator's
whole shared index before they have chosen which folder is actually their
media library would burn both TMDB's rate limit and the node's CPU
on files that were never meant to be in the Videos app at all. Once a
root is set, `_enrich_video_root_now` (called when it changes)
separately sweeps whatever it already contains — this path alone only
ever sees entries new since the last broadcast.
"""
if not self._enricher or not self._roster:
return
video_dirs = await self._roster.app_directories(indexer.group_id, "video")
if not video_dirs:
return
for entry in entries:
if entry.type != "video" or (indexer.group_id, entry.id) in self._enriched_attempted:
continue
if not _under_any_directory(entry.path, video_dirs):
continue
file_path = entry_abs_path(indexer.roots, entry)
if not file_path or not file_path.exists():
continue
self._enriched_attempted.add((indexer.group_id, entry.id))
async def on_done(file_id: str, fields: dict, _indexer=indexer) -> None:
await self._on_enriched(_indexer, file_id, fields)
self._enricher.spawn(entry, file_path, on_done)
async def _enrich_video_root_now(self, group_id: str) -> None:
"""
Videos app: sweep a group's existing index for enrichment right
after its video root is set or changed.
The ordinary path above only ever looks at entries new since the
last broadcast, so a folder that already had files sitting in it
before it became the video_root would otherwise never get enriched
at all — nothing else re-visits already-indexed entries once they
have been broadcast once.
"""
indexer = self._state.get("indexers", {}).get(group_id)
if not indexer:
return
await self._enrich_new_video_entries(indexer, list(indexer.index.entries))
async def _reenrich_renamed_video_entries(
self, indexer: DirectoryIndexer, updates: list, previous: GroupIndex,
) -> None:
"""
Videos app: found live — a French-named episode file, renamed by
the operator to match its English-named siblings, kept showing as
its own separate poster-grid card (and its own row in Flat list)
indefinitely, because `_enriched_attempted` — there specifically to
stop enrichment's own field-fill from re-triggering itself forever
(see the caller) — also silently blocked the *new* filename from
ever being title-parsed at all. `entry.id in self._enriched_attempted`
is the same content, so simply discarding it here and re-running
the ordinary enrichment path is enough: a fresh ffprobe/thumbnail
for an unchanged file is redundant work, not a correctness issue,
and renames are rare enough that the redundancy is not worth a
separate "title-parse only" code path.
"""
for entry in updates:
if entry.type != "video":
continue
old = previous.get_entry(entry.id)
if old is None or (old.name == entry.name and old.path == entry.path):
continue
self._enriched_attempted.discard((indexer.group_id, entry.id))
# The rename re-derives the title (the whole point of this
# method), which can change the correct TMDB match — but
# file_tmdb is keyed by content hash, unchanged by a rename, so
# nothing else would ever dislodge the old name's match. A
# manual "Fix match" correction is kept (clear_file_tmdb skips
# anything in media_cache.tmdb_override).
if self._media_cache is not None:
await self._media_cache.clear_file_tmdb(entry.id)
await self._enrich_new_video_entries(indexer, updates)
async def _enrich_new_audio_entries(self, indexer: DirectoryIndexer, entries: list) -> None:
"""
Music app (docs/MESHBAY_DESIGN.md §9.8): fire (never await further)
tag/cover enrichment for unattempted audio entries under the
group's configured audio_root — same gate as
`_enrich_new_video_entries` above (the original "no root, whole
shared tree" call turned out wrong against a real messy library:
everything under every shared folder got mixed together with no way
to scope it down). `_enriched_attempted` is shared with the video
path — content-addressed ids never collide across the two.
"""
if not self._audio_enricher or not self._roster:
return
audio_dirs = await self._roster.app_directories(indexer.group_id, "music")
if not audio_dirs:
return
# Resolved once per directory, not once per file: a library is
# thousands of entries and this is a filesystem call each time.
boundaries = {d: indexer.roots.resolve(d, require_available=False)
for d in audio_dirs}
for entry in entries:
if entry.type != "audio" or (indexer.group_id, entry.id) in self._enriched_attempted:
continue
owner = _owning_directory(entry.path, audio_dirs)
if owner is None:
continue
file_path = entry_abs_path(indexer.roots, entry)
if not file_path or not file_path.exists():
continue
self._enriched_attempted.add((indexer.group_id, entry.id))
async def on_done(file_id: str, fields: dict, _indexer=indexer) -> None:
await self._on_enriched(_indexer, file_id, fields)
# The boundary is *the configured directory this file is under*,
# not the shared root it lives in — so the ancestor walk
# (enrich_audio._artist_album_from_ancestors) treats a flat
# top-level folder right under the configured Music directory as
# ambiguous (artist-or-release, docs/MESHBAY_DESIGN.md §9.8),
# rather than one level too shallow when that directory is
# itself a subfolder. With several configured, each file is
# measured against its own:
# a single shared boundary would be wrong for all but one of them.
self._audio_enricher.spawn(entry, file_path, on_done,
boundaries.get(owner))
async def _enrich_audio_root_now(self, group_id: str) -> None:
"""
Music app: sweep a group's existing index right after its
audio root is set or changed. Mirrors
`_enrich_video_root_now` exactly — the ordinary path above only
ever looks at entries new since the last broadcast, so a folder
that already had files in it before it became the audio_root would
otherwise never get enriched at all.
"""
indexer = self._state.get("indexers", {}).get(group_id)
if not indexer:
return
await self._enrich_new_audio_entries(indexer, list(indexer.index.entries))
async def _reenrich_renamed_audio_entries(
self, indexer: DirectoryIndexer, updates: list, previous: GroupIndex,
) -> None:
"""
Music app equivalent of `_reenrich_renamed_video_entries` — a rename
can change the filename-parse fallback (title/track_no) even though
embedded tags, when present, are unaffected. Re-running the whole
pass on a rename is redundant work for a tagged file and a real fix
for an untagged one, and renames are rare enough not to need a
cheaper, tags-only special case.
"""
for entry in updates:
if entry.type != "audio":
continue
old = previous.get_entry(entry.id)
if old is None or (old.name == entry.name and old.path == entry.path):
continue
self._enriched_attempted.discard((indexer.group_id, entry.id))
await self._enrich_new_audio_entries(indexer, updates)
async def _enrich_new_photo_entries(self, indexer: DirectoryIndexer, entries: list) -> None:
"""
Photos app (docs/MESHBAY_DESIGN.md §9.9): fire (never await further)
thumbnail/EXIF enrichment for unattempted image entries under any of the
group's configured photo_roots. Same gate shape as
`_enrich_new_video_entries`/`_enrich_new_audio_entries` — no root
configured yet means no work, since thumbnailing every image in a
whole shared tree before the operator has chosen which folders are
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.
"""
if not self._photo_enricher or not self._roster:
return
photo_dirs = await self._roster.app_directories(indexer.group_id, "photo")
if not photo_dirs:
return
for entry in entries:
if entry.type != "image" or (indexer.group_id, entry.id) in self._enriched_attempted:
continue
if not _under_any_directory(entry.path, photo_dirs):
continue
file_path = entry_abs_path(indexer.roots, entry)
if not file_path or not file_path.exists():
continue
self._enriched_attempted.add((indexer.group_id, entry.id))
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)
async def _enrich_photo_roots_now(self, group_id: str) -> None:
"""
Photos app: sweep a group's existing index right after its
photo roots change. Mirrors
`_enrich_video_root_now`/`_enrich_audio_root_now` — the ordinary
path above only ever looks at entries new since the last broadcast,
so a folder that already had photos in it before it was added to
photo_roots would otherwise never get enriched at all. Also covers
a root being *removed*: nothing un-enriches on removal (the cache
entry is harmless, just unused — the media cache is disposable and
tied to the index, docs/MESHBAY_DESIGN.md §6.5), so re-sweeping the
new set is enough.
"""
indexer = self._state.get("indexers", {}).get(group_id)
if not indexer:
return
await self._enrich_new_photo_entries(indexer, list(indexer.index.entries))
async def _reenrich_renamed_photo_entries(
self, indexer: DirectoryIndexer, updates: list, previous: GroupIndex,
) -> None:
"""
Photos app equivalent of `_reenrich_renamed_video_entries` — a
rename changes nothing about the image's own bytes (thumbnail, EXIF
fields are content-derived, not name-derived), so this exists only
for consistency/symmetry with Videos/Music and to catch the case of
a file moving *into* a newly-covered photo_roots subtree via a
rename rather than a fresh add. Re-running enrichment on an
unchanged file is redundant work, not a correctness issue.
"""
for entry in updates:
if entry.type != "image":
continue
old = previous.get_entry(entry.id)
if old is None or (old.name == entry.name and old.path == entry.path):
continue
self._enriched_attempted.discard((indexer.group_id, entry.id))
await self._enrich_new_photo_entries(indexer, updates)
async def _on_enriched(self, indexer: DirectoryIndexer, file_id: str, fields: dict) -> None:
"""
Merge enrichment fields into the live index and re-trigger a
broadcast so they reach connected clients as an INDEX_DELTA update
(GroupIndex.diff's `updates`, not `additions` — same id, new fields).
Builds a *new* IndexEntry via dataclasses.replace rather than
mutating the existing one in place: the diff mechanism compares
against a shallow snapshot of entry *references*, so an in-place
mutation would silently also change what "previous" looks like,
and the change would never show up as a diff (see group_index.py's
diff() docstring).
"""
idx = indexer.index
entry = idx.get_entry(file_id)
if entry is None:
return # removed from the index while enrichment was in flight
idx.add_entry(replace(entry, **fields))
await self._on_index_change(indexer)
|