aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-19 17:42:11 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-19 17:42:11 +0200
commit171a3e175889ce30f201fcdf5c4632f480344ae6 (patch)
tree7e5dd693f8281faa25e3bb6c5651bbb212eb78c2
parent34d74cba0421ac88505ac620158882f90cf5db7e (diff)
downloadmeshbay-171a3e175889ce30f201fcdf5c4632f480344ae6.tar.gz
fix(node): the Music app's tags survive a restart
media_cache held video_meta, photo_meta and thumbs; the audio tags lived only in the in-memory IndexEntry. So every start re-read every audio file the node serves, and until that pass landed it served an index with no artist on any track — one the Music app cannot group. Over a real 6176-file library the pass costs 27.8s cold and 6.4s from audio_meta. Only what the bytes decided is stored. The filename and folder fallbacks still run live, or a renamed file would get the old name's answer; the sibling-cover scan reads the folder, so it stays live too; and a read that failed is not cached, or one bad read becomes permanent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--CLAUDE.md18
-rw-r--r--docs/MESHBAY_DESIGN.md1
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py96
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_cache.py64
-rw-r--r--packages/meshbay-node/tests/test_audio_meta_cache.py236
5 files changed, 383 insertions, 32 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 694f7b2..5463437 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -493,6 +493,24 @@ do. Read them before writing anything that touches the same mechanism.
viewport from the layout one), which was plausible, cost a round trip, and
was wrong; the screenshot showing no keyboard was already in hand.
+- **Derived data that nothing persists is re-derived where somebody is
+ watching.** `video_meta`, `photo_meta` and `thumbs` were durable; the audio
+ tags were not, so every node start re-read every audio file it serves and,
+ until that pass landed, served an index with no artist on any track — an
+ index the Music app cannot group. Nobody noticed for months because the
+ re-read is cheap and the node is usually up. `media_cache.audio_meta` holds
+ it now: the pass over a real 6176-file library costs 27.8s cold and 6.4s
+ cached. **What goes in such a cache is what the file's bytes decided, never
+ the field as the app finally sees it** — `display_title`, `track_no`, artist
+ and album can also come from the filename and the folder, and a rename has to
+ re-derive those; storing the resolved field would hand a renamed file the old
+ name's answer, which is the fault `_reenrich_renamed_audio_entries` exists to
+ prevent. The sibling-cover scan stays live for the mirror-image reason: it
+ reads the *folder*, so caching "no cover" would make a cover dropped in
+ afterwards unfindable. And a read that *failed* is not an answer — caching
+ "this file says nothing" for a drive that did not respond makes one bad read
+ permanent
+
- **A view that draws one shape of unit, beside an emptiness test that counts
every shape.** The Music grid's unit is an album; a track whose artist tag is
empty belongs to none, so it was drawn nowhere — while `empty` counted it and
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 8baf984..76c8b08 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -3129,7 +3129,6 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows.
| **QUIC** | Off by default, and **not at parity**: it serves the index and file chunks with no transfer lease, no leaseless ceiling and no root-availability check, does its file I/O on the event loop, and returns exception text to the peer (**L3**). No client speaks it. Either it comes to parity or it goes; until then §5.1's "chat is the only gap" is the one sentence here that overstates the code |
| **The relay registry** | **Closed in the code**: `relay.RELAYS_ENABLED` is False and every `/v1/relays` route answers 503, as federation does. Nothing in the tree calls them, node or client, and §11.1 measured two ISPs with no TURN relay needed. Kept code that nothing calls is what **L7** says not to keep; it stays only as the proof-of-possession design (**AV6**) until a node needs a relay or it is deleted |
| **Per-device revocation has no CLI** | A device is revoked over MNP (`roster.revoke_device`), from a device the node has already pinned. On a headless node the operator's only lever is `member unpin`, which removes **every** device of that account — so the per-device control the roster is built around is reachable from an interface and from nowhere else. §6.7 listed a `meshbay-node member device list\|revoke` verb that was never written, and that listing is how this was found: `USERGUIDE.md` was the first document written by reading the CLI rather than this specification, and the verb it copied out did not run |
-| **Music is the one app whose index-time enrichment is not persisted** | `media_cache.db` holds `video_meta`, `photo_meta`, `file_tmdb`, `file_mbid` and `thumbs`; there is no audio table, so `artist`/`album`/`track_no` live only in the in-memory `IndexEntry`. Every node start re-reads every audio file's tags (measured: 6176 files, 6.4s of `mutagen` reads, ~25s with cover work) and until that pass lands the index it serves has no artist on any track — the window a client reconnecting to a just-restarted node arrives in. Cheap to read and therefore never noticed, right up until something reads the index during it |
| **Migrations run on SQLite only** | The chain reaches head and agrees with the models there (§12), which is not where it ships. **The exposure is one revision deep, not the whole chain**: every revision behind the first packaged release was development that no installation ever ran, so nothing replays them on PostgreSQL. What is unguarded is the *next* migration — a default, an index type or a constraint PostgreSQL refuses reaches a deploy without the suite saying so |
---
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
index d484e35..dd6b2c0 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
@@ -320,16 +320,30 @@ def _read_tags_and_cover(
tags["title"] = title_parse.strip_track_prefix(tags["title"]) or tags["title"]
if cover is None and not skip_cover:
- sibling = _find_sibling_cover(path.parent)
- if sibling is not None:
- try:
- cover = sibling.read_bytes()
- except OSError:
- cover = None
+ cover = _read_sibling_cover(path)
return tags, duration, cover
+def _read_sibling_cover(path: Path) -> bytes | None:
+ """
+ The cover image sitting beside the track, as bytes.
+
+ Its own function because it is the one part of the read that depends on the
+ *folder* rather than on the file's bytes: `audio_meta` remembers what the
+ bytes said and lets `AudioEnricher._run` skip opening the file at all, and
+ this still has to run on top of that, or a cover dropped in after the first
+ pass could never be found again. Blocking; called via asyncio.to_thread.
+ """
+ sibling = _find_sibling_cover(path.parent)
+ if sibling is None:
+ return None
+ try:
+ return sibling.read_bytes()
+ except OSError:
+ return None
+
+
# A folder name used as a last-resort artist/album, cleaned of the
# punctuation-as-separator and release-tag noise this era of rip is full of
# (underscores standing in for spaces, a bitrate/quality tag still attached
@@ -450,33 +464,53 @@ class AudioEnricher:
async with self._sem:
fields: dict = {}
- # entry.id is the file's own content hash — a cover already
- # cached under it means this exact content's cover was already
- # extracted (this run, an earlier one, even a previous daemon
- # process), so the second file open (embedded APIC/covr scan)
- # and the sibling-directory disk read are both skippable.
+ # entry.id is the file's own content hash, so everything a read of
+ # those bytes produced is cacheable under it — the tags, the
+ # duration, and whether the embedded-art scan has already run
+ # (media_cache.audio_meta, plus get_thumb_hash_by_file_id for the
+ # cover itself). With both in hand the file is not opened at all,
+ # which is the whole point: this pass used to re-read every audio
+ # file on the node at every start, and until it landed the index
+ # the node served carried no artist on any track.
#
- # Tags themselves are *not* skipped this way, deliberately:
- # unlike a cover, artist/album/title/track_no can fall back to
- # the filename or the folder name (_artist_album_from_ancestors
- # above) when no tag is present, which is exactly what a rename
- # needs re-derived — _reenrich_renamed_audio_entries exists for
- # precisely that. Caching the *result* the same way enrich.py's
- # duration/width/height is cached doesn't apply cleanly here:
- # mutagen reads tags and duration in the same call as cover, so
- # skipping that call to save time would also skip the
- # rename-sensitive fields, and skipping only the parts that are
- # safe to skip needs the cover check below, not a separate
- # cache of the tag-derived fields.
+ # What is *not* cached, and must not be: the fallbacks below.
+ # artist/album/title/track_no can come from the filename or the
+ # folder name (_artist_album_from_ancestors above) when no tag is
+ # present, and those a rename has to re-derive —
+ # _reenrich_renamed_audio_entries exists for precisely that. The
+ # raw tag is not rename-sensitive, so the tag is what is stored and
+ # the chain still runs live on top of it. The sibling-image scan is
+ # the other one: it reads the folder, not the file, so a cover
+ # dropped in afterwards must still be found.
cached_thumb_hash = await self._media_cache.get_thumb_hash_by_file_id(entry.id)
- try:
- tags, duration, cover = await asyncio.wait_for(
- asyncio.to_thread(
- _read_tags_and_cover, file_path, skip_cover=bool(cached_thumb_hash)),
- timeout=READ_TIMEOUT_SECS)
- except Exception as e:
- log.warning("Tag read failed for %s: %s", file_path, e)
- tags, duration, cover = {}, None, None
+ cached = await self._media_cache.get_audio_meta(entry.id)
+ cover_settled = bool(cached_thumb_hash) or bool(cached and cached["cover_seen"])
+ if cached and cover_settled:
+ tags = {k: cached[k] for k in ("title", "artist", "album", "track_no")
+ if cached[k] is not None}
+ duration = cached["duration"]
+ cover = None if cached_thumb_hash else await asyncio.to_thread(
+ _read_sibling_cover, file_path)
+ else:
+ try:
+ tags, duration, cover = await asyncio.wait_for(
+ asyncio.to_thread(
+ _read_tags_and_cover, file_path, skip_cover=bool(cached_thumb_hash)),
+ timeout=READ_TIMEOUT_SECS)
+ except Exception as e:
+ # Not cached. A file that genuinely carries no tags reads
+ # fine and returns an empty dict, which is an answer worth
+ # keeping; this is a drive that did not answer, and writing
+ # "says nothing" for it would make one bad read permanent.
+ log.warning("Tag read failed for %s: %s", file_path, e)
+ tags, duration, cover = {}, None, None
+ else:
+ # `cover_seen` is false when the scan was skipped, so a file
+ # whose cover was cached and has since been evicted is
+ # looked at again rather than left without one for good.
+ await self._media_cache.put_audio_meta(
+ entry.id, tags, int(duration) if duration else None,
+ cover_seen=not cached_thumb_hash)
if duration:
fields["duration"] = int(duration)
diff --git a/packages/meshbay-node/src/meshbay_node/media_cache.py b/packages/meshbay-node/src/meshbay_node/media_cache.py
index 9dfdf73..9c692ca 100644
--- a/packages/meshbay-node/src/meshbay_node/media_cache.py
+++ b/packages/meshbay-node/src/meshbay_node/media_cache.py
@@ -107,6 +107,40 @@ CREATE TABLE IF NOT EXISTS mbid_meta (
-- hit had already proven unnecessary. thumb_hash is not duplicated here —
-- get_thumb_hash_by_file_id(file_id) already answers that, and a second copy
-- would just be one more place for the two to drift.
+-- Music app (docs/MESHBAY_DESIGN.md §9.8): what a read of the file's own bytes
+-- produced. Music was the one app whose index-time enrichment survived
+-- nothing: `video_meta` and `photo_meta` are here, `thumbs` is here, and
+-- artist/album/track_no/title lived only in the in-memory IndexEntry. So every
+-- node start re-read the tags of every audio file it serves, and until that
+-- pass landed the index it served carried no artist on any track — which is an
+-- index the Music app cannot group, and looks from the outside like a tab that
+-- lost its content. Measured over a real 6176-file library: the pass costs
+-- 27.8s with nothing cached and 6.4s with this table populated. It does not
+-- make the node's start-up window vanish — video enrichment and re-sealing an
+-- 8845-entry index dominate that — it makes the artist and the album come back
+-- in seconds rather than in tens of them.
+--
+-- Only what the bytes decide, for the reason video_meta states: `display_title`
+-- and `track_no` as the app finally sees them can also come from the *filename*
+-- (title_parse, and _artist_album_from_ancestors for artist/album), and a
+-- rename has to re-derive those — _reenrich_renamed_audio_entries exists for
+-- it. The raw tag is not rename-sensitive, so it is what is stored, and the
+-- fallback chain still runs live on top of it.
+--
+-- `cover_seen` records that the *embedded* art scan ran for this content;
+-- combined with get_thumb_hash_by_file_id it says whether the file has to be
+-- opened again at all. The sibling-image scan is deliberately not covered by
+-- it — that one reads the *folder*, so a cover dropped in later must still be
+-- found, and it costs 0.8s across the same library.
+CREATE TABLE IF NOT EXISTS audio_meta (
+ file_id TEXT PRIMARY KEY,
+ title TEXT,
+ artist TEXT,
+ album TEXT,
+ track_no INTEGER,
+ duration INTEGER,
+ cover_seen INTEGER NOT NULL DEFAULT 0
+);
CREATE TABLE IF NOT EXISTS photo_meta (
file_id TEXT PRIMARY KEY,
width INTEGER,
@@ -460,6 +494,34 @@ class MediaCache:
)
await self._db.commit()
+ # ── audio tags (Music app) ───────────────────────────────────────────────
+ #
+ # The tag as read, never the field as the app finally sees it — see the
+ # schema comment on why the filename-derived half stays out.
+
+ async def get_audio_meta(self, file_id: str) -> dict | None:
+ async with self._db.execute(
+ "SELECT title, artist, album, track_no, duration, cover_seen "
+ "FROM audio_meta WHERE file_id = ?",
+ (file_id,),
+ ) as cur:
+ row = await cur.fetchone()
+ if not row:
+ return None
+ return {"title": row[0], "artist": row[1], "album": row[2],
+ "track_no": row[3], "duration": row[4], "cover_seen": bool(row[5])}
+
+ async def put_audio_meta(self, file_id: str, tags: dict, duration: int | None,
+ cover_seen: bool) -> None:
+ await self._db.execute(
+ "INSERT OR REPLACE INTO audio_meta "
+ "(file_id, title, artist, album, track_no, duration, cover_seen) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (file_id, tags.get("title"), tags.get("artist"), tags.get("album"),
+ tags.get("track_no"), duration, 1 if cover_seen else 0),
+ )
+ await self._db.commit()
+
# ── video technical fields (Videos app) ──────────────────────────────────
#
# duration/width/height only — see the schema comment on why
@@ -498,6 +560,8 @@ class MediaCache:
await self._db.execute("DELETE FROM thumbs WHERE file_id = ?", (file_id,))
await self._db.execute("DELETE FROM photo_meta WHERE file_id = ?", (file_id,))
await self._db.execute("DELETE FROM video_meta WHERE file_id = ?", (file_id,))
+ await self._db.execute(
+ "DELETE FROM audio_meta WHERE file_id = ?", (file_id,))
await self._db.execute("DELETE FROM file_tmdb WHERE file_id = ?", (file_id,))
await self._db.execute("DELETE FROM tmdb_override WHERE file_id = ?", (file_id,))
await self._db.execute("DELETE FROM file_mbid WHERE file_id = ?", (file_id,))
diff --git a/packages/meshbay-node/tests/test_audio_meta_cache.py b/packages/meshbay-node/tests/test_audio_meta_cache.py
new file mode 100644
index 0000000..e6ee315
--- /dev/null
+++ b/packages/meshbay-node/tests/test_audio_meta_cache.py
@@ -0,0 +1,236 @@
+"""
+The Music app's index-time enrichment survives a restart.
+
+`video_meta`, `photo_meta` and `thumbs` were all durable; the audio tags were
+not, so every node start re-read the tags of every audio file it serves, and
+until that pass landed the index it served carried no artist on any track. A
+client that asked in that window got an index the Music app cannot group — a
+toolbar over a blank page, for as long as the page stayed open.
+
+What is cached is what the file's *bytes* said. The filename and folder
+fallbacks on top of it are not, and these tests are mostly about that line:
+a cache that also remembered the derived fields would hand a renamed file the
+old name's answer, which is the fault `_reenrich_renamed_audio_entries` exists
+to prevent.
+"""
+import asyncio
+import subprocess
+from pathlib import Path
+
+import pytest
+from meshbay_common.protocol import IndexEntry
+from meshbay_node.indexer import enrich_audio
+from meshbay_node.indexer.enrich_audio import AudioEnricher
+from meshbay_node.media_cache import MediaCache
+
+_HAVE_FFMPEG = (
+ subprocess.run(["which", "ffmpeg"], capture_output=True).returncode == 0)
+
+pytestmark = pytest.mark.skipif(not _HAVE_FFMPEG, reason="ffmpeg not installed")
+
+
+def _make_clip(path: Path, *, title=None, artist=None, album=None, track=None) -> None:
+ subprocess.run(
+ ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
+ "-f", "lavfi", "-i", "sine=frequency=440:duration=1",
+ "-c:a", "libmp3lame", "-b:a", "64k",
+ *(["-metadata", f"title={title}"] if title else []),
+ *(["-metadata", f"artist={artist}"] if artist else []),
+ *(["-metadata", f"album={album}"] if album else []),
+ *(["-metadata", f"track={track}"] if track else []),
+ str(path)],
+ check=True, capture_output=True,
+ )
+
+
+@pytest.fixture
+async def media_cache(tmp_path):
+ c = MediaCache(db_path=tmp_path / "media_cache.db")
+ await c.open()
+ yield c
+ await c.close()
+
+
+async def _enrich(enricher, entry, clip, root_path=None):
+ done = asyncio.get_event_loop().create_future()
+
+ async def on_done(file_id, fields):
+ done.set_result(fields)
+
+ enricher.spawn(entry, clip, on_done, root_path)
+ return await asyncio.wait_for(done, timeout=30)
+
+
+@pytest.fixture
+def counted_reads(monkeypatch):
+ """How many times the audio file itself was opened and parsed."""
+ calls = []
+ real = enrich_audio._read_tags_and_cover
+
+ def counting(path, skip_cover=False):
+ calls.append(Path(path).name)
+ return real(path, skip_cover=skip_cover)
+
+ monkeypatch.setattr(enrich_audio, "_read_tags_and_cover", counting)
+ return calls
+
+
+@pytest.mark.asyncio
+async def test_a_second_pass_over_the_same_content_does_not_open_the_file(
+ tmp_path, media_cache, counted_reads):
+ """The restart case, in one process: same bytes, no second read."""
+ clip = tmp_path / "01 - a track.mp3"
+ _make_clip(clip, title="A Title", artist="An Act", album="A Record", track=2)
+ entry = IndexEntry(id="content1", name=clip.name, path=clip.name,
+ size=clip.stat().st_size, type="audio", added_at=0)
+
+ first = await _enrich(AudioEnricher(media_cache), entry, clip)
+ assert len(counted_reads) == 1
+
+ # A different enricher, as a restarted daemon would build — only the cache
+ # on disk is shared.
+ second = await _enrich(AudioEnricher(media_cache), entry, clip)
+ assert len(counted_reads) == 1, "the file was opened again despite a cache hit"
+ assert second == first, "a cache hit must produce the fields the read produced"
+
+
+@pytest.mark.asyncio
+async def test_the_cached_answer_is_the_tags_and_the_duration(tmp_path, media_cache):
+ clip = tmp_path / "01 - a track.mp3"
+ _make_clip(clip, title="A Title", artist="An Act", album="A Record", track=2)
+ entry = IndexEntry(id="content2", name=clip.name, path=clip.name,
+ size=clip.stat().st_size, type="audio", added_at=0)
+ await _enrich(AudioEnricher(media_cache), entry, clip)
+
+ row = await media_cache.get_audio_meta("content2")
+ assert row["title"] == "A Title"
+ assert row["artist"] == "An Act"
+ assert row["album"] == "A Record"
+ assert row["track_no"] == 2
+ assert row["duration"] == 1
+ assert row["cover_seen"] is True
+
+
+@pytest.mark.asyncio
+async def test_a_file_that_says_nothing_is_remembered_as_saying_nothing(
+ tmp_path, media_cache, counted_reads):
+ """
+ The commonest row in a real library, and the one worth caching most: an
+ untagged file costs exactly the same open as a tagged one to learn nothing
+ from.
+ """
+ folder = tmp_path / "Some Act" / "Some Record"
+ folder.mkdir(parents=True)
+ clip = folder / "05 - Filename Title.mp3"
+ _make_clip(clip)
+ entry = IndexEntry(id="content3", name=clip.name,
+ path=str(clip.relative_to(tmp_path)),
+ size=clip.stat().st_size, type="audio", added_at=0)
+
+ first = await _enrich(AudioEnricher(media_cache), entry, clip, tmp_path)
+ assert await media_cache.get_audio_meta("content3") is not None
+
+ second = await _enrich(AudioEnricher(media_cache), entry, clip, tmp_path)
+ assert len(counted_reads) == 1
+ # Still resolved, and from the folder rather than from any tag.
+ assert second["artist"] == "Some Act"
+ assert second["album"] == "Some Record"
+ assert second == first
+
+
+@pytest.mark.asyncio
+async def test_a_renamed_file_re_derives_what_the_name_decides(
+ tmp_path, media_cache, counted_reads):
+ """
+ The line the cache must not cross. Same content, so the tags are reused —
+ and `display_title`/`track_no`/artist/album still come from the new name
+ and the new folder, which is what `_reenrich_renamed_audio_entries` asks
+ for when it discards the attempt.
+ """
+ first_folder = tmp_path / "Old Act" / "Old Record"
+ first_folder.mkdir(parents=True)
+ clip = first_folder / "05 - Old Name.mp3"
+ _make_clip(clip) # no tags at all: the name decides
+ entry = IndexEntry(id="content4", name=clip.name,
+ path=str(clip.relative_to(tmp_path)),
+ size=clip.stat().st_size, type="audio", added_at=0)
+ before = await _enrich(AudioEnricher(media_cache), entry, clip, tmp_path)
+ assert before["display_title"] == "Old Name"
+ assert before["artist"] == "Old Act"
+
+ moved_folder = tmp_path / "New Act" / "New Record"
+ moved_folder.mkdir(parents=True)
+ moved = moved_folder / "07 - New Name.mp3"
+ clip.rename(moved)
+ renamed = IndexEntry(id="content4", name=moved.name,
+ path=str(moved.relative_to(tmp_path)),
+ size=moved.stat().st_size, type="audio", added_at=0)
+
+ after = await _enrich(AudioEnricher(media_cache), renamed, moved, tmp_path)
+ assert len(counted_reads) == 1, "the bytes did not change; the file need not be reopened"
+ assert after["display_title"] == "New Name", "the cache answered for the old name"
+ assert after["track_no"] == 7
+ assert after["artist"] == "New Act"
+ assert after["album"] == "New Record"
+
+
+@pytest.mark.asyncio
+async def test_a_cover_dropped_in_afterwards_is_still_found(tmp_path, media_cache):
+ """
+ The sibling scan reads the *folder*, so it stays live on top of the cache.
+ Caching it would have made "no cover" permanent for content that later got
+ one — and the scan costs 0.8s across a 6000-file library, measured.
+ """
+ folder = tmp_path / "An Act" / "A Record"
+ folder.mkdir(parents=True)
+ clip = folder / "01 - a track.mp3"
+ _make_clip(clip)
+ entry = IndexEntry(id="content5", name=clip.name,
+ path=str(clip.relative_to(tmp_path)),
+ size=clip.stat().st_size, type="audio", added_at=0)
+
+ first = await _enrich(AudioEnricher(media_cache), entry, clip, tmp_path)
+ assert first.get("thumb_hash") is None
+
+ (folder / "cover.jpg").write_bytes(b"\xff\xd8\xff\xe0 not really a jpeg")
+ second = await _enrich(AudioEnricher(media_cache), entry, clip, tmp_path)
+ assert second.get("thumb_hash"), "the cache made a missing cover permanent"
+
+
+@pytest.mark.asyncio
+async def test_an_unreadable_file_is_not_remembered_as_empty(tmp_path, media_cache):
+ """
+ A drive that did not answer is not a file that carries no tags. Writing
+ "says nothing" for it would make one bad read permanent.
+ """
+ clip = tmp_path / "01 - a track.mp3"
+ _make_clip(clip, title="A Title", artist="An Act")
+ entry = IndexEntry(id="content6", name=clip.name, path=clip.name,
+ size=clip.stat().st_size, type="audio", added_at=0)
+
+ def boom(path, skip_cover=False):
+ raise OSError("the drive said no")
+
+ real = enrich_audio._read_tags_and_cover
+ enrich_audio._read_tags_and_cover = boom
+ try:
+ await _enrich(AudioEnricher(media_cache), entry, clip)
+ finally:
+ enrich_audio._read_tags_and_cover = real
+ assert await media_cache.get_audio_meta("content6") is None
+
+ fields = await _enrich(AudioEnricher(media_cache), entry, clip)
+ assert fields["artist"] == "An Act", "the failed read was cached and never retried"
+
+
+@pytest.mark.asyncio
+async def test_a_file_leaving_the_index_takes_its_row_with_it(tmp_path, media_cache):
+ clip = tmp_path / "01 - a track.mp3"
+ _make_clip(clip, artist="An Act")
+ entry = IndexEntry(id="content7", name=clip.name, path=clip.name,
+ size=clip.stat().st_size, type="audio", added_at=0)
+ await _enrich(AudioEnricher(media_cache), entry, clip)
+ assert await media_cache.get_audio_meta("content7") is not None
+
+ await media_cache.prune_file("content7")
+ assert await media_cache.get_audio_meta("content7") is None