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
|
"""
A root that comes back keeps its Videos/Music/Photos metadata.
Reported live against a real library: a removable root ejected from the
Files app and plugged back in came back with its files but *without* its
albums — Music showed "no music found" and stayed that way through a force
reload, because the loss was on the node, not in the client.
`DirectoryIndexer.plug_root` drops the root's entries and rescans, which is
right: the drive may have changed while it was away. What it produces is
bare `IndexEntry` objects — `_hash_or_cached` fills id/name/path/size/type
and nothing else. Every enrichment field (`artist`, `album`, `track_no`,
`duration`, `thumb_hash`, `display_title`, `taken_at`, ...) is gone, and
unlike the video/photo probe results those tag fields are cached nowhere:
`enrich_audio.py` deliberately re-reads them each time so a rename can
re-derive the filename fallback. Re-enrichment is the only way back.
Two independent gates then made sure it never ran:
* `_broadcast_index_change` schedules enrichment for `delta.additions`.
Ejecting never broadcasts, so `_last_broadcast_snapshot` still held those
ids — the re-added entries diffed as *updates*, not additions.
* `_enrich_new_*_entries` skips anything already in `_enriched_attempted`,
which is only discarded for `delta.deletions`. Dropping and rescanning
inside one call means no deletion is ever broadcast, so the mark survived
a wipe of the very fields it was standing for.
A restart cleared both (an empty snapshot makes every entry an addition),
which is why this looked like it might fix itself and never did.
The same drop-and-rescan runs in `reconcile()` — "Root %r is back" — so a
USB drive that falls off and returns on its own hits this without anybody
touching the UI.
"""
import asyncio
import os
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.media_cache import MediaCache
from meshbay_node.roots import RootSet
from meshbay_node.roster import Roster
pytestmark = pytest.mark.asyncio
# Above indexer.py's MIN_AUDIO_SIZE_BYTES, or nothing would be indexed.
_AUDIO_BYTES = os.urandom(60 * 1024)
def _free_port() -> int:
import socket
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class _CountingEnricher:
"""Stands in for AudioEnricher: records who it was asked to enrich."""
def __init__(self):
self.spawned: list[str] = []
def spawn(self, entry, file_path, on_done, boundary=None):
self.spawned.append(entry.name)
async def _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=_free_port(),
)],
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._roster = Roster(db_path=tmp_path / "roster.db")
await daemon._roster.open()
return daemon
async def _settled(daemon, indexer):
await daemon._on_index_change(indexer)
await asyncio.sleep(0.05)
async def test_a_replugged_root_gets_its_music_metadata_back(tmp_path):
group_id = "a" * 32
library = tmp_path / "music"
(library / "an album").mkdir(parents=True)
(library / "an album" / "track.mp3").write_bytes(_AUDIO_BYTES)
daemon = await _daemon(tmp_path, library, group_id)
enricher = _CountingEnricher()
daemon._audio_enricher = enricher
try:
await daemon._roster.set_app_directories(
group_id, "music", ["music/an album"], set_by="op")
roots = RootSet.build([{"path": str(library), "removable": True}])
indexer = DirectoryIndexer(
roots=roots, group_id=group_id,
sk_node=Ed25519PrivateKey.generate(), gek=generate_gek())
await indexer.initial_scan()
await _settled(daemon, indexer)
assert enricher.spawned == ["track.mp3"], "the first pass never ran"
# The operator ejects the drive from Files, then plugs it back in.
indexer.eject_root("music")
await indexer.plug_root("music")
await _settled(daemon, indexer)
entry = next(iter(indexer.index.entries))
assert entry.artist is None and entry.album is None, (
"the rescan is supposed to produce a bare entry — if this ever "
"stops being true the rest of this test is measuring nothing")
assert enricher.spawned == ["track.mp3", "track.mp3"], (
"a replugged root came back with its files and without its "
"albums, and nothing was ever going to fill them in again")
finally:
await daemon._media_cache.close()
await daemon._roster.close()
async def test_a_root_that_returns_on_its_own_is_treated_the_same(tmp_path):
"""
`reconcile()` rescans a root that reappears without anyone asking — a USB
drive re-mounting. It goes through the same drop-and-rescan, so it loses
the same fields, with no click anywhere to blame it on.
"""
group_id = "b" * 32
library = tmp_path / "music"
(library / "an album").mkdir(parents=True)
(library / "an album" / "track.mp3").write_bytes(_AUDIO_BYTES)
daemon = await _daemon(tmp_path, library, group_id)
enricher = _CountingEnricher()
daemon._audio_enricher = enricher
try:
await daemon._roster.set_app_directories(
group_id, "music", ["music/an album"], set_by="op")
roots = RootSet.build([{"path": str(library), "removable": True}])
indexer = DirectoryIndexer(
roots=roots, group_id=group_id,
sk_node=Ed25519PrivateKey.generate(), gek=generate_gek())
await indexer.initial_scan()
await _settled(daemon, indexer)
assert enricher.spawned == ["track.mp3"]
# Gone, then back — availability is what reconcile() watches.
roots.roots[0].available = False
await indexer.reconcile()
await _settled(daemon, indexer)
await indexer.reconcile()
await _settled(daemon, indexer)
assert enricher.spawned.count("track.mp3") >= 2, (
"a drive that fell off and came back left the library with no "
"metadata until the next daemon restart")
finally:
await daemon._media_cache.close()
await daemon._roster.close()
async def test_videos_and_photos_lost_the_same_fields(tmp_path):
"""
Nothing here is specific to Music — Videos and Photos hang off the same
`new_entries` list in `_broadcast_index_change`, so a replug took their
durations, titles and thumbnails with it too. Music is simply where it
shows up loudest: a track with no tags has no album to file it under, so
the app goes empty rather than merely plain.
"""
group_id = "c" * 32
library = tmp_path / "media"
(library / "films").mkdir(parents=True)
(library / "films" / "clip.mkv").write_bytes(os.urandom(60 * 1024))
(library / "album").mkdir(parents=True)
(library / "album" / "shot.jpg").write_bytes(os.urandom(60 * 1024))
daemon = await _daemon(tmp_path, library, group_id)
video, photo = _CountingEnricher(), _CountingEnricher()
daemon._enricher, daemon._photo_enricher = video, photo
try:
await daemon._roster.set_app_directories(
group_id, "video", ["media/films"], set_by="op")
await daemon._roster.set_app_directories(
group_id, "photo", ["media/album"], set_by="op")
roots = RootSet.build([{"path": str(library), "removable": True}])
indexer = DirectoryIndexer(
roots=roots, group_id=group_id,
sk_node=Ed25519PrivateKey.generate(), gek=generate_gek())
await indexer.initial_scan()
await _settled(daemon, indexer)
assert video.spawned == ["clip.mkv"] and photo.spawned == ["shot.jpg"]
indexer.eject_root("media")
await indexer.plug_root("media")
await _settled(daemon, indexer)
assert video.spawned == ["clip.mkv"] * 2, "the film lost its probe"
assert photo.spawned == ["shot.jpg"] * 2, "the photo lost its thumbnail"
finally:
await daemon._media_cache.close()
await daemon._roster.close()
|