summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_rename_reenrichment.py
blob: 87e0d1abc051f785d5c01d1bf4aa2bfee0b2d4a4 (plain) (blame)
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
"""
Bug found live, 2026-08-24: an episode file first named in French (its
release folder mixed languages across seasons) was renamed by the operator
to match its English-named siblings — but kept showing as its own
separate poster-grid card, and its own row in Flat list, indefinitely.

`_enriched_attempted` (daemon.py) exists so that enrichment's own
field-fill (duration/thumb_hash/display_title/... landing back via
`_on_enriched`) does not re-trigger itself forever — but it also silently
blocked the *new* filename from ever being title-parsed at all, since the
file's content (and so its id) is unchanged by a rename. A rename/move is
exactly the case `_reenrich_renamed_video_entries` exists to detect: same
id, but `name` or `path` differs from the version last broadcast.
"""

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, HubConfig, NodeConfig, GroupConfig, KeystoreConfig
from meshbay_node.daemon import NodeDaemon
from meshbay_node.indexer import DirectoryIndexer

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]


class _StubRoster:
    async def video_root(self, group_id):
        return "shared"


class _SpyEnricher:
    """Records which file ids were actually (re-)scheduled, without
    needing a real ffmpeg/ffprobe pipeline for this test."""

    def __init__(self):
        self.spawned = []

    def spawn(self, entry, file_path, on_done):
        self.spawned.append(entry.id)

        async def _noop():
            return None

        return asyncio.ensure_future(_noop())


async def test_a_renamed_file_gets_re_enriched(tmp_path):
    group_id = "a" * 32
    shared = tmp_path / "shared"
    shared.mkdir()
    old_path = shared / "la-guerre-des-mondes-s03e02.mkv"
    old_path.write_bytes(b"not a real video, just needs to be indexed as one")

    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=29016,
        )],
        keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
        data_dir=tmp_path / "data",
    )
    daemon = NodeDaemon(config)
    daemon._broadcast_coalesce_secs = 0.01
    daemon._enricher = _SpyEnricher()
    daemon._roster = _StubRoster()

    indexer = DirectoryIndexer(
        roots=one_root(shared), group_id=group_id,
        sk_node=Ed25519PrivateKey.generate(), gek=generate_gek(),
        # reconcile() below calls this itself — the initial scan doesn't
        # (see test_startup_scan_enrichment.py), so that first broadcast is
        # still triggered manually, matching _bg_scan's real sequence.
        on_change=daemon._on_index_change)
    await indexer.initial_scan()
    await daemon._on_index_change(indexer)
    await asyncio.sleep(0.05)

    entry = next(iter(indexer.index.entries))
    file_id = entry.id
    assert daemon._enricher.spawned == [file_id], (
        "the file must be scheduled for enrichment once, under its original name")

    new_path = shared / "war-of-the-worlds-s03e02.mkv"
    old_path.rename(new_path)
    changed = await indexer.reconcile()
    assert changed, "the rename must actually be picked up by reconcile()"
    # The re-broadcast is a fire-and-forget task chained behind the
    # coalescing timer, itself scheduling another fire-and-forget task —
    # poll rather than guess a single sleep long enough for both hops.
    for _ in range(30):
        if len(daemon._enricher.spawned) >= 2:
            break
        await asyncio.sleep(0.02)

    renamed_entry = indexer.index.get_entry(file_id)
    assert renamed_entry is not None
    assert renamed_entry.name == "war-of-the-worlds-s03e02.mkv"
    assert daemon._enricher.spawned == [file_id, file_id], (
        "a rename must re-schedule enrichment for the same file id — "
        "_enriched_attempted must not permanently block the new filename "
        "from ever being title-parsed")


async def test_an_unrelated_update_does_not_re_trigger_enrichment(tmp_path):
    """
    The other half of the same fix: an update whose name/path did *not*
    change (the ordinary case — enrichment's own field-fill, or the
    reconcile sweep confirming a file unmodified) must not re-schedule
    enrichment. Without this, `_on_enriched` merging a file's own results
    back into the index would count as its own trigger and loop forever.
    """
    group_id = "a" * 32
    shared = tmp_path / "shared"
    shared.mkdir()
    path = shared / "movie.mkv"
    path.write_bytes(b"not a real video, just needs to be indexed as one")

    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=29017,
        )],
        keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
        data_dir=tmp_path / "data",
    )
    daemon = NodeDaemon(config)
    daemon._broadcast_coalesce_secs = 0.01
    daemon._enricher = _SpyEnricher()
    daemon._roster = _StubRoster()

    indexer = DirectoryIndexer(
        roots=one_root(shared), group_id=group_id,
        sk_node=Ed25519PrivateKey.generate(), gek=generate_gek(),
        on_change=daemon._on_index_change)
    await indexer.initial_scan()
    await daemon._on_index_change(indexer)
    await asyncio.sleep(0.05)

    entry = next(iter(indexer.index.entries))
    assert daemon._enricher.spawned == [entry.id]

    # A reconcile pass that finds nothing changed at all — not even a
    # rename — must not re-schedule anything.
    changed = await indexer.reconcile()
    await asyncio.sleep(0.05)
    assert not changed
    assert daemon._enricher.spawned == [entry.id]