forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
1import * as Orama from "@orama/orama";
2import { xxh32 } from "xxh32";
3
4import { SCHEMA } from "./constants.js";
5import { announce, ostiary, rpc } from "~/common/worker.js";
6import { effect, signal } from "~/common/signal.js";
7
8/**
9 * @import {SearchParams} from "@orama/orama";
10 *
11 * @import {Track} from "~/definitions/types.d.ts"
12 * @import {Actions, Schema} from "./types.d.ts"
13 */
14
15////////////////////////////////////////////
16// STATE
17////////////////////////////////////////////
18
19/** @type {Set<string>} */
20export let inserted = new Set();
21export let insertedFingerprint = "";
22
23// Communicated state
24export const $supplyFingerprint = signal(
25 /** @type {string | undefined} */ (undefined),
26);
27
28////////////////////////////////////////////
29// DATABASE
30////////////////////////////////////////////
31
32/**
33 * @type {Orama.OramaPlugin[]}
34 */
35const PLUGINS = [];
36
37const db = Orama.create({
38 schema: SCHEMA,
39 plugins: PLUGINS,
40});
41
42////////////////////////////////////////////
43// ACTIONS
44////////////////////////////////////////////
45
46/**
47 * @type {Actions['search']}
48 */
49export async function search(params) {
50 return await _search(
51 "term" in params && typeof params.term === "string"
52 ? { ...params, term: params.term.trim() }
53 : params,
54 [],
55 );
56}
57
58/**
59 * @type {Actions['supply']}
60 */
61export async function supply({ tracks }) {
62 // TODO: Generate a hash based on the track itself,
63 // so we can detect changes to tags or other data.
64
65 /** @type {Map<string, Track>} */
66 const tracksMap = new Map();
67
68 for (const track of tracks) {
69 if (!track.tags) continue;
70 tracksMap.set(track.id, track);
71 }
72
73 const ids = Array.from(tracksMap.keys());
74 const idsString = ids.sort().join("");
75 const fingerprint = xxh32(idsString).toString();
76
77 if (fingerprint === insertedFingerprint) return;
78
79 const currentSet = inserted;
80 const newSet = new Set(ids);
81
82 inserted = newSet;
83 insertedFingerprint = fingerprint;
84
85 const removedIds = currentSet.difference(newSet);
86 const newIds = newSet.difference(currentSet);
87
88 /** @type {Track[]} */
89 const newTracks = [];
90
91 for (const id of newIds) {
92 const track = tracksMap.get(id);
93 if (track) newTracks.push(track);
94 }
95
96 await Orama.removeMultiple(db, Array.from(removedIds));
97 await Orama.insertMultiple(db, newTracks);
98
99 $supplyFingerprint.value = fingerprint;
100}
101
102////////////////////////////////////////////
103// ⚡️
104////////////////////////////////////////////
105
106ostiary((context) => {
107 rpc(context, {
108 search,
109 supply,
110
111 // State
112 supplyFingerprint: $supplyFingerprint.get,
113 });
114
115 // Effects
116 // -------
117
118 // Communicate state
119 effect(() =>
120 announce("supplyFingerprint", $supplyFingerprint.value, context)
121 );
122});
123
124////////////////////////////////////////////
125// ⛔️
126////////////////////////////////////////////
127
128/**
129 * @param {SearchParams<Schema>} params
130 * @param {Track[]} tracks
131 */
132async function _search(params, tracks) {
133 const results = await Orama.search(db, {
134 // @ts-ignore: No clue what the correct type is for this one
135 sortBy,
136 ...params,
137 threshold: 0, // AND operator: all search terms must match in at least one property
138 limit: 10000,
139 offset: tracks.length,
140 });
141
142 const allTracks = tracks.concat(
143 results.hits.map((
144 hit,
145 ) => /** @type {Track} */ (/** @type {unknown} */ (hit.document))),
146 );
147
148 if (allTracks.length < results.count) {
149 return await _search(params, allTracks);
150 } else {
151 return allTracks;
152 }
153}
154
155/**
156 * @type {Orama.CustomSorterFunction<Orama.TypedDocument<Schema>>}
157 */
158function sortBy(a, b) {
159 const artist = (a[2].tags?.artist ?? "").localeCompare(
160 b[2].tags?.artist ?? "",
161 );
162 if (artist != 0) return artist;
163
164 const album = (a[2].tags?.album ?? "").localeCompare(
165 b[2].tags?.album ?? "",
166 );
167 if (album != 0) return album;
168
169 const discNo = (a[2].tags?.disc?.no ?? 0) - (b[2].tags?.disc?.no ?? 0);
170 if (discNo != 0) return discNo;
171
172 const trackNo = (a[2].tags?.track?.no ?? 0) - (b[2].tags?.track?.no ?? 0);
173 if (trackNo != 0) return trackNo;
174
175 const title = (a[2].tags?.title ?? "").localeCompare(
176 b[2].tags?.title ?? "",
177 );
178 if (title != 0) return title;
179 return 0;
180}