aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
blob: 61565e8a8f5c46ba875e74c379d36c6c3bb6d189 (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
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
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
import {
  html, useState, useEffect, useRef, useMemo, useCallback,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
import { Icon } from './icon.js';
import { canPreview, downloadEntry } from './file-utils.js';
import {
  HUB, session, hubFetch, ensureFreshToken, _loadBundleKey,
} from './hub-client.js';
import { FilesPanel, FilePreview } from './files-app.js';
import { VideoApp, groupVideoEntries } from './video-app.js';
import { MusicApp, groupMusicEntries, foldKey } from './music-app.js';
import { PhotosApp, groupPhotoAlbums } from './photos-app.js';
import { VideoPlayer } from './video-player.js';
import { transfers } from './transfers.js';
import { mergeUnitEntries } from './source-merge.js';
import { useStickyBand } from './sticky.js';

// How many connections this page negotiates at once — a ceiling on concurrency,
// never a batch (see `inFlight` below), and one ceiling for everything here: the
// sweep, the warm-up and a tile asking for its group all go through
// `ConnectionPool.connect`, which holds it. Three separate ceilings used to add
// up behind each other's backs, past what the hub admits per account.
//
// Six, sized for twenty groups on a phone: a connection there takes about two
// seconds, so three at a time is fourteen seconds to reach them all and six is
// seven, and a node that is down holds one place in six rather than one in
// three. The hub admits 32 pending offers per account (signaling.py), which
// leaves room for this page on three devices at once plus their reconnections.
const MAX_IN_FLIGHT = 6;
// One WebRTC peer connection per group the search view touches. The cap bounds
// how many a busy account (many groups on the hub) keeps open at once; groups
// past it connect lazily when a tile of theirs scrolls into view (video-app.js's
// LazyTile → onNeedConn). Was 3, which meant any account with more than three
// groups thrashed the pool: an evicted transport left a stale `_tRef` on every
// tile of that group, and MediaThumb / useMediaMeta gave up on it with no
// retry — so posters rendered for a moment, then fell back to a spinner for
// good. grenet (one shared group) never hit it; cbesson (many) always did.
const MAX_POOL_SIZE = 12;
const DEBOUNCE_MS = 200;
// How long a connection attempt may make **no progress**, not how long it may
// take. Search used a flat 10 s for the whole of `connect()`, which is the hub
// round trip, ICE gathering (capped at 4 s in transport.js), DTLS, the
// DataChannel opening and the handshake's own round trips. Opening the same
// group from the sidebar has no such deadline and gets the transport's own 30 s,
// so a link slow enough to need twelve seconds — a phone on 4G — failed here and
// worked from there, for the same work against the same node.
//
// A node that is not answering produces no progress event and still fails in
// `SEARCH_STALL_MS`, which is what keeps the fan-out bounded: a dead group holds
// one of the `MAX_IN_FLIGHT` places for this long, so the number that matters
// for a page full of unreachable groups is this one. A hub that refuses an offer
// for load *is* answering, and transport.js reports each retry as progress.
// `SEARCH_MAX_MS` bounds the other case — a node that answers ICE and then stops
// — because a deadline that only ever resets has none.
const SEARCH_STALL_MS = 10000;
const SEARCH_MAX_MS = 30000;
const SEARCH_VIDEO_ROOT = '__search__';
const SEARCH_AUDIO_ROOT = '__search__';
const SEARCH_PHOTO_ROOTS = ['__search_photos__'];

// -- Connecting to a group ----------------------------------------------------

/**
 * A live connection to one of the nodes serving `groupId`, and its ack — or
 * null when the hub lists no node at all.
 *
 * Every node the hub lists, in turn, exactly as group-page.js does since
 * 2026-09-11: the list is in hub registration order, and its head is not
 * necessarily a node that answers. Search took `nodes[0]` and stopped, so a
 * group with a second, working node counted as unreachable here while it
 * opened fine from the sidebar. A refusal naming a state of this browser (a
 * code, a passphrase, a device) is the same from every node and stops the
 * walk; `not_hosted`, a timeout or a failed connection moves on.
 */
async function connectToGroup(hubBase, groupId, token, bundleKey, username, userId) {
  const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token });
  if (!nodesData.nodes || !nodesData.nodes.length) return null;

  const live = (await ensureFreshToken()) || token;
  let lastErr = null;
  for (const n of nodesData.nodes) {
    const transport = new window.MeshBayTransport(hubBase, live);
    transport.onNeedToken = async () => (await ensureFreshToken()) || token;
    let stallTimer, capTimer;
    const stopTimers = () => {
      clearTimeout(stallTimer);
      clearTimeout(capTimer);
      transport.onConnectProgress = null;
    };
    try {
      const ack = await Promise.race([
        transport.connect(
          n.node_id, live, groupId, null, null, bundleKey,
          username, userId, null),
        new Promise((_, reject) => {
          const giveUp = () => reject(new Error('Connection timeout'));
          stallTimer = setTimeout(giveUp, SEARCH_STALL_MS);
          capTimer = setTimeout(giveUp, SEARCH_MAX_MS);
          // Each step the transport reports — the peer answering ICE, the
          // channel opening — buys another window, never more than the cap.
          transport.onConnectProgress = () => {
            clearTimeout(stallTimer);
            stallTimer = setTimeout(giveUp, SEARCH_STALL_MS);
          };
        }),
      ]);
      stopTimers();
      return { transport, ack };
    } catch (e) {
      stopTimers();
      lastErr = e;
      try { transport.close(); } catch {}
      if (e.reason && e.reason !== 'not_hosted') throw e;
    }
  }
  throw (lastErr || new Error('no node served this group'));
}

// -- Connection pool ----------------------------------------------------------

/**
 * Every connection this page makes, and the only way it makes one.
 *
 * The sweep, the warm-up and a tile asking for its group all come here, so the
 * page has one ceiling on how many it negotiates at once (`MAX_IN_FLIGHT`) and
 * one connection per group. The sweep used to open its own, fetch the index,
 * close it, and leave the warm-up to open the same group again straight after:
 * two offers per group per visit, and on 4G the two overlapped, which is how a
 * phone ran into the hub's per-account ceilings with five groups.
 */
class ConnectionPool {
  constructor(hubBase, onEvict) {
    this._hubBase = hubBase;
    this._connections = new Map();
    this._connecting = new Map();
    // Called with a groupId whenever this pool closes that group's connection
    // (eviction, close or closeAll). SearchPage uses it to drop its own record
    // so it never hands a tile a `_tRef` pointing at a transport closed here.
    this._onEvict = onEvict || (() => {});
    // Negotiations under way, and those waiting for one of the places.
    this._active = 0;
    this._waiting = [];
    this._closed = false;
  }

  get size() { return this._connections.size; }

  get closed() { return this._closed; }

  /** Whether `groupId` has a connection here that using costs no offer. */
  has(groupId) {
    const conn = this._connections.get(groupId);
    return !!(conn && conn.transport.connected);
  }

  /**
   * The group's connection, negotiating one if there is none.
   *
   * `hold` keeps it from being evicted until `release` — the sweep holds each
   * group while its index is on the way, because with more groups than the pool
   * keeps, the connection it is reading from would otherwise be the oldest one
   * and closed under it.
   */
  async connect(groupId, token, bundleKey, username, userId, { hold = false } = {}) {
    if (this._closed) throw new Error('Search was closed');
    let conn = this._connections.get(groupId);
    if (!(conn && conn.transport.connected)) {
      let p = this._connecting.get(groupId);
      if (!p) {
        p = this._negotiate(groupId, token, bundleKey, username, userId);
        this._connecting.set(groupId, p);
        p.finally(() => this._connecting.delete(groupId)).catch(() => {});
      }
      conn = await p;
    }
    conn.lastUsed = Date.now();
    if (hold) conn.holds += 1;
    this._evict();
    return conn;
  }

  release(conn) {
    conn.holds = Math.max(0, conn.holds - 1);
    this._evict();
  }

  /** Drop one group's connection, e.g. one that stopped answering. */
  close(groupId) {
    const conn = this._connections.get(groupId);
    if (!conn) return;
    try { conn.transport.close(); } catch {}
    this._connections.delete(groupId);
    this._onEvict(groupId);
  }

  async _negotiate(groupId, token, bundleKey, username, userId) {
    await this._takePlace();
    let conn;
    try {
      // Waiting for a place is not part of any deadline: `connectToGroup`'s
      // timers start inside `_doConnect`, once this negotiation is really on.
      if (this._closed) throw new Error('Search was closed');
      conn = await this._doConnect(groupId, token, bundleKey, username, userId);
    } finally {
      this._givePlace();
    }
    if (this._closed) {
      try { conn.transport.close(); } catch {}
      throw new Error('Search was closed');
    }
    // A connection that dropped is replaced, and closed so its own reconnect
    // loop does not keep negotiating a second one for the same group.
    const stale = this._connections.get(groupId);
    if (stale && stale.transport !== conn.transport) {
      try { stale.transport.close(); } catch {}
    }
    this._connections.set(groupId, conn);
    return conn;
  }

  _takePlace() {
    if (this._active < MAX_IN_FLIGHT) {
      this._active += 1;
      return Promise.resolve();
    }
    return new Promise((resolve) => this._waiting.push(resolve));
  }

  _givePlace() {
    const next = this._waiting.shift();
    if (next) next();          // handed straight over: `_active` is unchanged
    else this._active -= 1;
  }

  async _doConnect(groupId, token, bundleKey, username, userId) {
    const found = await connectToGroup(
      this._hubBase, groupId, token, bundleKey, username, userId);
    if (!found) throw new Error('offline');
    const { transport, ack } = found;

    let gek = null;
    if (transport.gekRaw && window.MeshBayCrypto) {
      gek = await window.MeshBayCrypto.importGEK(
        window.MeshBayCrypto.b64encode(transport.gekRaw));
    }
    return { transport, gek, ack, lastUsed: Date.now(), holds: 0 };
  }

  _evict() {
    while (this._connections.size > MAX_POOL_SIZE) {
      let oldestId = null, oldestTime = Infinity;
      for (const [id, conn] of this._connections) {
        if (conn.holds > 0) continue;
        if (conn.lastUsed < oldestTime) { oldestTime = conn.lastUsed; oldestId = id; }
      }
      // Everything over the size is held: it goes when it is released.
      if (!oldestId) break;
      this.close(oldestId);
    }
  }

  closeAll() {
    // In-flight negotiations see this when they finish and close what they got.
    this._closed = true;
    for (const [id, conn] of this._connections) {
      try { conn.transport.close(); } catch {}
      this._onEvict(id);
    }
    this._connections.clear();
  }
}

// -- Index fetching -----------------------------------------------------------

// How long a connection the pool already holds gets to prove it is alive before
// its index is asked for. A phone that slept keeps reporting `connected` on a
// channel that is gone, and an index request on it would wait thirty seconds.
const REUSE_PING_MS = 4000;

/**
 * One group's index, over the pool's connection to it — which the page then
 * keeps for its tiles, so reaching a group costs one offer and not two.
 */
async function fetchGroupIndex(pool, groupId, token, bundleKey, username, userId) {
  if (pool.has(groupId)) {
    const held = await pool.connect(groupId, token, bundleKey, username, userId);
    try { await held.transport.ping(REUSE_PING_MS); } catch { pool.close(groupId); }
  }
  const conn = await pool.connect(
    groupId, token, bundleKey, username, userId, { hold: true });
  const { transport, ack } = conn;

  let unlisted = false;
  try {
    // The operator asked for this group to stay out of the global listing.
    // Decided here, before the index is asked for, so nothing of it is held,
    // cached or merged by this page. A listing preference and not a boundary:
    // the node cannot tell this request from the group page's, and opening the
    // group lists everything.
    if (ack.search_listed === false) {
      unlisted = true;
      return { unlisted: true };
    }

    const indexMsg = await transport.fetchIndex();
    // Plural, with the old scalars as the fallback for a node still speaking
    // MNP 1.0 — the same reading group-page.js does on its own handshake.
    const roots = {
      video: ack.video_directories || [],
      music: ack.music_directories || [],
      photo: ack.photo_directories || [],
    };
    // Which of the reader's groups sit on their own node — the tie-breaker
    // when the same file is announced by several of them
    // (docs/MESHBAY_DESIGN.md §9.11). Computed by the node from its own
    // record of who it belongs to (webrtc_server.py's _is_node_admin), never
    // from a hub claim, and deliberately not written to the index cache: it
    // describes this connection, not the group's content.
    return { entries: indexMsg.entries || [], roots, isNodeAdmin: !!ack.is_node_admin };
  } finally {
    pool.release(conn);
    // Nothing of an unlisted group is shown, so nothing will use its connection.
    if (unlisted) pool.close(groupId);
  }
}

/**
 * Run `fn` over `items`, `n` at a time, starting the next the moment one ends.
 *
 * Deliberately not batches. A batch waits for its slowest member before the
 * next one starts, and a group whose node is down is always the slowest member
 * there is: it costs the full connection deadline. Measured on twelve groups
 * with four of them down — the shape a reader really has — batches of three
 * showed the first result after ten seconds and finished after forty, because
 * every dead group stalled its own batch and postponed all the ones behind it.
 * A dead group here occupies one of `n` places and holds up nothing else.
 */
async function inFlight(items, n, fn) {
  let next = 0;
  const worker = async () => {
    while (next < items.length) {
      const i = next;
      next += 1;
      await fn(items[i], i);
    }
  };
  await Promise.all(
    Array.from({ length: Math.min(n, items.length) }, worker));
}

// Which groups did not answer last time, so they can be dialled last.
//
// A ceiling of three still leaves one bad case: if the first three groups the
// hub lists are all down, all three places are held for the full deadline and
// nothing else starts. A node that is off tends to stay off, so the browser
// remembers and puts the known-silent ones at the back — where their deadline
// runs beside results that are already on screen instead of in front of them.
//
// Per browser and advisory: unreadable storage, a private window, a first visit
// or a stale list all degrade to the hub's own order, which is the behaviour
// without this. It is rewritten after every sweep, so a node coming back costs
// one sweep of being last and then returns to its place.
const DOWN_KEY = 'meshbay.search.down';

function lastKnownDown() {
  try {
    return new Set(JSON.parse(localStorage.getItem(DOWN_KEY) || '[]'));
  } catch {
    return new Set();
  }
}

function rememberDown(ids) {
  try {
    localStorage.setItem(DOWN_KEY, JSON.stringify([...ids]));
  } catch { /* private window, or storage refused: the order is advisory */ }
}

async function fetchAllIndexes(pool, groups, token, username, userId, onProgress, onResult) {
  const bundleKey = session.bundleKey || await _loadBundleKey();
  if (bundleKey) session.bundleKey = bundleKey;

  const total = groups.length;
  let done = 0;
  const unreachable = [];
  const downNow = new Set();
  const results = new Map();

  // Stable, so groups that answered keep the hub's order among themselves.
  const wasDown = lastKnownDown();
  const queue = [...groups].sort(
    (a, b) => (wasDown.has(a.id) ? 1 : 0) - (wasDown.has(b.id) ? 1 : 0));

  await inFlight(queue, MAX_IN_FLIGHT, async (g) => {
    try {
      const result = await fetchGroupIndex(pool, g.id, token, bundleKey, username, userId);
      if (result && result.unlisted) {
        // Left out of Search by its operator: not shown, and not down either.
      } else if (result) {
        results.set(g.id, {
          ...result,
          groupName: g.name,
          groupOwner: g.owner_username,
        });
        // Drawn now, not when this group's neighbours are done. Its index is
        // already in hand; holding it back until a group that is not answering
        // has finished not answering is ten seconds of blank page for work that
        // completed in two hundred milliseconds.
        onResult(results);
      } else {
        unreachable.push(g.name || g.id);
        downNow.add(g.id);
      }
    } catch (e) {
      unreachable.push(g.name || g.id);
      // Still refused after transport.js's retries: the hub was busy, which
      // says nothing about this group's node, so it keeps its place next time.
      if (!(e && e.status === 429)) downNow.add(g.id);
    }
    done++;
    onProgress({ done, total, unreachable: [...unreachable] });
  });
  // A sweep cut short by the page closing saw nothing about any node: every
  // group left was refused by this page, not by its node.
  if (!(pool && pool.closed)) rememberDown(downNow);
  return { results, unreachable };
}

// -- SearchPage ---------------------------------------------------------------

function underRoot(entry, directories) {
  const dirs = directories || [];
  if (!dirs.length) return false;
  const p = entry.path || '';
  return dirs.some((d) => p === d || p.startsWith(d + '/'));
}

/**
 * An app's directories out of a cached group, in either shape.
 *
 * The cache lives in IndexedDB and outlives a deploy, so a reader opening
 * Search after this ships still has entries written by the previous version —
 * `{videoRoot: 'X'}` where this now writes `{video: ['X']}`. Reading only the
 * new shape would empty their Videos results with no explanation and no way
 * to tell it from "nothing matched".
 */
function cachedDirs(roots, appKey, legacyKey) {
  if (!roots) return [];
  const fresh = roots[appKey];
  if (Array.isArray(fresh)) return fresh;
  const legacy = roots[legacyKey];
  if (Array.isArray(legacy)) return legacy;
  return legacy ? [legacy] : [];
}

// -- Merging the same file announced by several groups ------------------------
//
// A directory shared by two groups — the reason two groups exist at all:
// different people invited to different libraries — arrived here as two
// entries per file, so a film showed as two poster cards and every episode
// twice inside a show. `source-merge.js` folds them on the content hash and
// resolves one source per *unit*. See docs/MESHBAY_DESIGN.md §9.11.
//
// The units come from video-app.js's own `groupVideoEntries`, never from a
// second copy of its keys here: a copy would keep agreeing with the original
// right up until one of them changed, and the symptom would be a show whose
// episodes stream from two different nodes. Running it twice per recompute (it
// runs again inside VideoApp) is a linear pass over an index already in memory
// and already re-walked on every keystroke of the filter.
//
// One naive unit per copy of a film rather than a pre-grouped one:
// `mergeUnitEntries` folds lists that share a key, so the two copies become
// one unit without this having to group them first.
function videoUnits(entries) {
  const { movies, shows } = groupVideoEntries(entries, [SEARCH_VIDEO_ROOT]);
  return [
    ...movies.map((e) => ({ key: `movie:${e.id}`, entries: [e] })),
    ...shows.map((s) => ({ key: `show:${s.title}`, entries: s.episodes })),
  ];
}

// An album is a unit, and so is a track loose enough to have no artist at all.
// `groupMusicEntries` has already folded the two groups' copies into one album
// object, so the key only has to name it stably — hence `foldKey` over the
// display strings, which are whichever spelling arrived first.
function musicUnits(entries) {
  const { tracks, albums } = groupMusicEntries(entries, [SEARCH_AUDIO_ROOT]);
  return [
    ...albums.map((a) => ({
      key: `album:${foldKey(a.artist)}/${foldKey(a.album)}`,
      entries: a.tracks,
    })),
    ...tracks.map((e) => ({ key: `track:${e.id}`, entries: [e] })),
  ];
}

// A photo album is its directory, which is already prefixed per group when the
// entries are built — so two groups whose roots have different basenames stay
// two albums, and the same photo belongs in both. Only same-named albums
// collapse, which is the reported shape.
function photoUnits(entries) {
  return groupPhotoAlbums(entries, SEARCH_PHOTO_ROOTS)
    .map((a) => ({ key: `album:${a.dir}`, entries: a.photos }));
}

function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) {
  const [indexedGroups, setIndexedGroups] = useState(new Map());
  const [progress, setProgress] = useState({ done: 0, total: 0, unreachable: [] });
  const [fetching, setFetching] = useState(false);
  // Bumped by the Files breadcrumb refresh button — re-runs the all-groups
  // index fetch below, the only "cache" this page has.
  const [refreshTick, setRefreshTick] = useState(0);
  const [query, setQuery] = useState('');
  const [viewMode, setViewMode] = useState('files');
  const [videoEntry, setVideoEntry] = useState(null);
  const [previewEntry, setPreviewEntry] = useState(null);
  const [connecting, setConnecting] = useState(false);
  const poolRef = useRef(null);
  const modalTransportRef = useRef(null);
  const modalGekRef = useRef(null);
  const groupConns = useRef(new Map());
  // groupId -> how many times its connection has been (re)established. Threaded
  // into each entry as `_connGen` and used by video-app.js's tiles as a refetch
  // key, so a group's posters/thumbnails recover the moment it reconnects
  // (after a pool eviction) instead of staying stuck on a spinner.
  const connGenRef = useRef(new Map());
  // groupIds whose connection failed — see markGroupDown below.
  const downGroups = useRef(new Set());
  const mountedRef = useRef(true);
  const [connectionGen, setConnectionGen] = useState(0);
  const debounceRef = useRef(null);
  const [debouncedQuery, setDebouncedQuery] = useState('');

  useEffect(() => {
    mountedRef.current = true;
    poolRef.current = new ConnectionPool(HUB, (evictedId) => {
      groupConns.current.delete(evictedId);
      // Rebuild the entry lists so tiles of the evicted group fall back to a
      // null `_tRef` (and pick a live one up again once reconnected).
      if (mountedRef.current) setConnectionGen((g) => g + 1);
    });
    return () => {
      mountedRef.current = false;
      if (poolRef.current) poolRef.current.closeAll();
    };
  }, []);

  useEffect(() => {
    if (debounceRef.current) clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(() => setDebouncedQuery(query), DEBOUNCE_MS);
    return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
  }, [query]);

  useEffect(() => {
    if (!groups || !groups.length || !token || !window.MeshBayTransport) return;
    let cancelled = false;

    (async () => {
      setFetching(true);
      setProgress({ done: 0, total: groups.length, unreachable: [] });

      if (!poolRef.current) return;
      await fetchAllIndexes(
        poolRef.current, groups, token, username, userId,
        (p) => { if (!cancelled) setProgress(p); },
        (results) => { if (!cancelled) setIndexedGroups(new Map(results)); },
      );

      if (!cancelled) setFetching(false);
    })();

    return () => { cancelled = true; };
  }, [groups, token, refreshTick]);

  // -- Connection management --

  // A group whose connection failed stops being chosen as a merged entry's
  // source, so a unit fails over to another group that has the file
  // (docs/MESHBAY_DESIGN.md §9.11). Without this the merge could make a
  // file *less* available than it was before it, which would be a regression
  // dressed as a feature.
  //
  // Held in a ref and read live by `mergeOpts.isDown`; what actually rebuilds
  // the entry lists is the `connectionGen` bump, which they already depend on.
  // Eviction is deliberately not a failure — `_onEvict` only drops the recorded
  // connection, and the group is picked again as readily as before.
  const markGroupDown = useCallback((groupId, down) => {
    let changed;
    if (down) {
      changed = !downGroups.current.has(groupId);
      downGroups.current.add(groupId);
    } else {
      changed = downGroups.current.delete(groupId);
    }
    if (changed && mountedRef.current) setConnectionGen((g) => g + 1);
  }, []);

  const connectGroup = useCallback(async (groupId) => {
    if (groupConns.current.has(groupId)) {
      const c = groupConns.current.get(groupId);
      if (c.transport && c.transport.connected) return c;
    }
    if (!poolRef.current) throw new Error('no pool');
    const bundleKey = session.bundleKey || await _loadBundleKey();
    let conn;
    try {
      conn = await poolRef.current.connect(groupId, token, bundleKey, username, userId);
    } catch (e) {
      markGroupDown(groupId, true);
      throw e;
    }
    markGroupDown(groupId, false);

    // A concurrent caller for the same group (several tiles mounting at once)
    // may already have recorded this exact transport while we awaited. Only
    // treat it as new — bump the per-group generation, force a rebuild — when
    // it genuinely is, so a group's tiles refetch once per (re)connect rather
    // than once per mounting tile.
    const existing = groupConns.current.get(groupId);
    if (existing && existing.transport === conn.transport) {
      existing.lastUsed = Date.now();
      return existing;
    }

    const gen = (connGenRef.current.get(groupId) || 0) + 1;
    connGenRef.current.set(groupId, gen);
    const entry = {
      transport: conn.transport,
      gek: conn.gek,
      gen,
      tRef: { current: conn.transport },
      gRef: { current: conn.gek },
    };
    groupConns.current.set(groupId, entry);
    // No bumpMediaMetaGeneration() / bumpThumbGeneration() here: a fresh
    // transport does not invalidate metadata another group already resolved,
    // and firing the module-wide reset on every connect is what made the whole
    // grid flicker through the pre-connect walk. Recovery for *this* group's
    // tiles comes from `_connGen` (threaded into its entries) instead; the
    // module-wide bumps stay for an operator's TMDB override/rematch only.
    setConnectionGen((g) => g + 1);
    return entry;
  }, [token, username, userId, markGroupDown]);

  // Hand the tiles their connections as soon as indexing finishes, so
  // thumbnails start loading before the user switches views. The sweep left
  // its connections in the pool, so for those this costs no offer at all; a
  // group is dialled here only when its connection has gone since and the pool
  // has room — never to evict one that is already open, which would be an offer
  // spent to lose another. Groups past the pool's size connect lazily when a
  // tile of theirs scrolls into view (onNeedConn, below). _thumbBlobCache keeps
  // fetched thumbnails across evictions.
  useEffect(() => {
    if (fetching || indexedGroups.size === 0) return;
    let cancelled = false;

    (async () => {
      const pool = poolRef.current;
      if (!pool) return;
      const ids = [...indexedGroups.keys()];
      const open = ids.filter((gid) => pool.has(gid));
      for (const gid of open) {
        if (cancelled) return;
        try { await connectGroup(gid); } catch { /* skip */ }
      }
      const room = Math.max(0, MAX_POOL_SIZE - pool.size);
      const closed = ids.filter((gid) => !pool.has(gid)).slice(0, room);
      await inFlight(closed, MAX_IN_FLIGHT, async (gid) => {
        if (cancelled) return;
        try { await connectGroup(gid); } catch { /* skip */ }
      });
    })();

    return () => { cancelled = true; };
  }, [fetching, indexedGroups, connectGroup]);

  // -- Entry preparation --

  const q = debouncedQuery.trim().toLowerCase();

  const matchesQuery = useCallback((e) => {
    if (!q) return true;
    return (e.name || '').toLowerCase().includes(q)
        || (e.path || '').toLowerCase().includes(q)
        || (e.display_title || '').toLowerCase().includes(q)
        || (e.artist || '').toLowerCase().includes(q)
        || (e.album || '').toLowerCase().includes(q);
  }, [q]);

  const allEntries = useMemo(() => {
    const result = [];
    for (const [groupId, data] of indexedGroups) {
      for (const e of data.entries) {
        result.push({
          ...e, groupId,
          groupName: data.groupName,
          groupOwner: data.groupOwner,
        });
      }
    }
    return result;
  }, [indexedGroups]);

  // Files view: path-prefixed entries for FilesPanel directory navigation
  const fileEntries = useMemo(() => {
    const result = [];
    for (const [groupId, data] of indexedGroups) {
      for (const e of data.entries) {
        if (q && !matchesQuery(e)) continue;
        const conn = groupConns.current.get(groupId);
        result.push({
          ...e,
          path: data.groupName + (e.path ? '/' + e.path : ''),
          _origPath: e.path,
          groupId,
          groupName: data.groupName,
          groupOwner: data.groupOwner,
          _tRef: conn ? conn.tRef : null,
          _gRef: conn ? conn.gRef : null,
          _connGen: conn ? conn.gen : 0,
        });
      }
    }
    return result;
  }, [indexedGroups, q, matchesQuery, connectionGen]);

  const fileNodeDirs = useMemo(() => {
    const dirs = [];
    for (const [, data] of indexedGroups) dirs.push(data.groupName);
    return dirs;
  }, [indexedGroups]);

  // How a unit's source is chosen, shared by every merged view
  // (docs/MESHBAY_DESIGN.md §9.11). `isLocal` reads the flag the node itself
  // put in the handshake ack — computed from its own record of who it belongs
  // to (webrtc_server.py's `_is_node_admin`), never from a hub claim.
  const mergeOpts = useMemo(() => ({
    salt: userId || '',
    isLocal: (gid) => {
      const data = indexedGroups.get(gid);
      return !!(data && data.isNodeAdmin);
    },
    // Read live off the ref rather than captured: what rebuilds the lists is
    // the `connectionGen` bump markGroupDown fires, and they already depend on
    // it. Putting the set in this memo's own dependencies would only add a
    // second reason to rebuild the same thing.
    isDown: (gid) => downGroups.current.has(gid),
  }), [indexedGroups, userId]);

  // Videos view: pre-filtered by videoRoot, path-prefixed, then merged so a
  // file several groups share is one card and one list entry.
  const videoEntries = useMemo(() => {
    const result = [];
    for (const [groupId, data] of indexedGroups) {
      const dirs = cachedDirs(data.roots, 'video', 'videoRoot');
      if (!dirs.length) continue;
      const conn = groupConns.current.get(groupId);
      for (const e of data.entries) {
        if (e.type !== 'video') continue;
        if (!underRoot(e, dirs)) continue;
        if (q && !matchesQuery(e)) continue;
        result.push({
          ...e,
          path: SEARCH_VIDEO_ROOT + '/' + e.path,
          groupId,
          groupName: data.groupName,
          groupOwner: data.groupOwner,
          _tRef: conn ? conn.tRef : null,
          _gRef: conn ? conn.gRef : null,
          _connGen: conn ? conn.gen : 0,
        });
      }
    }
    return mergeUnitEntries(videoUnits(result), mergeOpts);
  }, [indexedGroups, q, matchesQuery, connectionGen, mergeOpts]);

  // Music view: pre-filtered by audioRoot, path-prefixed, then merged per album
  const musicEntries = useMemo(() => {
    const result = [];
    for (const [groupId, data] of indexedGroups) {
      const dirs = cachedDirs(data.roots, 'music', 'audioRoot');
      if (!dirs.length) continue;
      const conn = groupConns.current.get(groupId);
      for (const e of data.entries) {
        if (e.type !== 'audio') continue;
        if (!underRoot(e, dirs)) continue;
        if (q && !matchesQuery(e)) continue;
        result.push({
          ...e,
          path: SEARCH_AUDIO_ROOT + '/' + e.path,
          groupId,
          groupName: data.groupName,
          groupOwner: data.groupOwner,
          _tRef: conn ? conn.tRef : null,
          _gRef: conn ? conn.gRef : null,
          _connGen: conn ? conn.gen : 0,
        });
      }
    }
    return mergeUnitEntries(musicUnits(result), mergeOpts);
  }, [indexedGroups, q, matchesQuery, connectionGen, mergeOpts]);

  // Photos view: pre-filtered by photoRoots, path-prefixed, then merged per album
  const photoEntries = useMemo(() => {
    const result = [];
    for (const [groupId, data] of indexedGroups) {
      const dirs = cachedDirs(data.roots, 'photo', 'photoRoots');
      if (!dirs.length) continue;
      const conn = groupConns.current.get(groupId);
      for (const e of data.entries) {
        if (e.type !== 'image') continue;
        const p = e.path || '';
        if (!dirs.some((d) => p === d || p.startsWith(d + '/'))) continue;
        if (q && !matchesQuery(e)) continue;
        result.push({
          ...e,
          path: '__search_photos__/' + e.path,
          groupId,
          groupName: data.groupName,
          groupOwner: data.groupOwner,
          _tRef: conn ? conn.tRef : null,
          _gRef: conn ? conn.gRef : null,
          _connGen: conn ? conn.gen : 0,
        });
      }
    }
    return mergeUnitEntries(photoUnits(result), mergeOpts);
  }, [indexedGroups, q, matchesQuery, connectionGen, mergeOpts]);

  // -- Callbacks --

  const onPreview = useCallback(async (entry) => {
    const groupId = entry.groupId;
    if (!groupId) return;

    if (entry.type === 'video') {
      setConnecting(true);
      try {
        const conn = await connectGroup(groupId);
        modalTransportRef.current = conn.transport;
        modalGekRef.current = conn.gek;
        setVideoEntry(entry);
      } catch { /* ignore */ }
      setConnecting(false);
      return;
    }

    if (entry.type === 'audio' && onPlayQueue) {
      const origPath = entry._origPath != null ? entry._origPath : entry.path;
      const siblings = allEntries
        .filter((e) => e.type === 'audio' && e.path === origPath && e.groupId === groupId)
        .sort((a, b) =>
          ((a.track_no == null ? 9999 : a.track_no) - (b.track_no == null ? 9999 : b.track_no))
          || (a.name || '').localeCompare(b.name || ''));
      const startIndex = Math.max(0, siblings.findIndex((e) => e.id === entry.id));
      onPlayQueue(siblings, startIndex);
      return;
    }

    if (canPreview(entry)) {
      setConnecting(true);
      try {
        const conn = await connectGroup(groupId);
        modalTransportRef.current = conn.transport;
        modalGekRef.current = conn.gek;
        setPreviewEntry(entry);
      } catch { /* ignore */ }
      setConnecting(false);
    }
  }, [allEntries, onPlayQueue, connectGroup]);

  // Same as group-page.js's wrapper: `op` must be named to survive the hop.
  const handleMusicPlay = useCallback((tracks, startIndex, op) => {
    if (!op || op === 'replace') setVideoEntry(null);
    if (onPlayQueue) onPlayQueue(tracks, startIndex, null, op);
  }, [onPlayQueue]);

  const downloadForModal = useCallback(async (entry) => {
    const transport = modalTransportRef.current;
    if (!transport || !transport.connected) return;
    await downloadEntry(transfers, transport, modalGekRef.current, entry);
  }, []);

  const getTransport = useCallback(async (entry) => {
    const conn = await connectGroup(entry.groupId);
    return { transport: conn.transport, gek: conn.gek };
  }, [connectGroup]);

  // No-op setters for FilesPanel
  const noop = useCallback(() => {}, []);
  // The search field and its view toggle are this page's equivalent of a
  // group's tab bar: the same band, pinned the same way, publishing the same
  // property for the toolbar underneath (style.css, "Sticky chrome").
  const searchBand = useStickyBand('--chrome-h');

  // -- Render --

  const totalEntries = allEntries.length;
  const hasResults = totalEntries > 0;
  const defaultTRef = useRef(null);
  const defaultGRef = useRef(null);

  return html`
    <div class="sticky-chrome">
      <div class="search-bar" ref=${searchBand}>
        <${Icon} name="search" />
        <input type="text"
          placeholder=${t('search.placeholder')}
          value=${query}
          onInput=${(e) => setQuery(e.target.value)}
          autofocus />
        ${query && html`
          <button class="search-bar-clear" onClick=${() => setQuery('')}>
            <${Icon} name="close" /></button>
        `}
        ${hasResults && html`
          <div class="view-toggle">
            <button class=${viewMode === 'files' ? 'active' : ''}
              onClick=${() => setViewMode('files')}
              title=${t('search.view_files')}>
              <${Icon} name="folder" /></button>
            <button class=${viewMode === 'videos' ? 'active' : ''}
              onClick=${() => setViewMode('videos')}
              title=${t('search.view_videos')}>
              <${Icon} name="video" /></button>
            <button class=${viewMode === 'music' ? 'active' : ''}
              onClick=${() => setViewMode('music')}
              title=${t('search.view_music')}>
              <${Icon} name="music" /></button>
            <button class=${viewMode === 'photos' ? 'active' : ''}
              onClick=${() => setViewMode('photos')}
              title=${t('search.view_photos')}>
              <${Icon} name="image" /></button>
          </div>
        `}
      </div>

      ${fetching && html`
        <div class="search-progress">
          <span class="spinner"></span>
          <span>${t('search.indexing', { done: progress.done, total: progress.total })}</span>
          <div class="search-progress-bar">
            <div class="search-progress-fill"
              style="width:${progress.total ? Math.round(100 * progress.done / progress.total) : 0}%"></div>
          </div>
        </div>
      `}

      ${progress.unreachable.length > 0 && html`
        <p class="search-unreachable">
          ${t('search.unreachable', { n: progress.unreachable.length })}
        </p>
      `}

      ${viewMode === 'files' && hasResults && html`
        <${FilesPanel}
          groupId="search"
          transportRef=${defaultTRef}
          gekRef=${defaultGRef}
          status="connected"
          entries=${fileEntries}
          nodeDirs=${fileNodeDirs}
          nodeRoots=${[]}
          setEntries=${noop}
          setNodeDirs=${noop}
          setNodeRoots=${noop}
          applyIndex=${noop}
          isNodeAdmin=${false}
          operatorPaired=${false}
          userId=${userId}
          setError=${noop}
          onPreview=${onPreview}
          showGroup=${true}
          readOnly=${true}
          getTransport=${getTransport}
          showRefresh=${true}
          onRefreshIndex=${() => setRefreshTick((n) => n + 1)} />
      `}

      ${viewMode === 'videos' && hasResults && html`
        <${VideoApp}
          groupId="search"
          transportRef=${defaultTRef}
          gekRef=${defaultGRef}
          status="connected"
          entries=${videoEntries}
          onPreview=${onPreview}
          videoDirectories=${[SEARCH_VIDEO_ROOT]}
          tmdbConfig=${{ enabled: true }}
          isNodeAdmin=${false}
          onNeedConn=${connectGroup}
          userPrefs=${userPrefs} pageResetKey=${q}
          hideFilter=${true} />
      `}

      ${viewMode === 'music' && hasResults && html`
        <${MusicApp}
          groupId="search"
          transportRef=${defaultTRef}
          gekRef=${defaultGRef}
          status="connected"
          entries=${musicEntries}
          musicDirectories=${[SEARCH_AUDIO_ROOT]}
          musicbrainzConfig=${{ enabled: true }}
          onPlayQueue=${handleMusicPlay} userId=${userId}
          userPrefs=${userPrefs} pageResetKey=${q}
          hideFilter=${true} />
      `}

      ${viewMode === 'photos' && hasResults && html`
        <${PhotosApp}
          groupId="search"
          transportRef=${defaultTRef}
          gekRef=${defaultGRef}
          status="connected"
          entries=${photoEntries}
          photoDirectories=${SEARCH_PHOTO_ROOTS}
          setError=${noop}
          hideFilter=${true}
          readOnly=${true} />
      `}

      ${!hasResults && !fetching && html`
        <p class="page-message">${t('search.hint')}</p>
      `}

      ${connecting && html`
        <div class="video-overlay" style="background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center">
          <span class="spinner"></span>
        </div>
      `}

      ${previewEntry && html`
        <${FilePreview}
          entry=${previewEntry}
          transportRef=${modalTransportRef}
          gekRef=${modalGekRef}
          onClose=${() => setPreviewEntry(null)}
          onDownload=${() => downloadForModal(previewEntry)} />
      `}
      ${videoEntry && html`
        <${VideoPlayer}
          entry=${videoEntry}
          transportRef=${modalTransportRef}
          gekRef=${modalGekRef}
          onClose=${() => setVideoEntry(null)}
          onDownload=${() => downloadForModal(videoEntry)} />
      `}
    </div>
  `;
}

export { SearchPage, ConnectionPool };