@freqhole/api-client 0.1.30 → 0.1.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/package.json +5 -2
- package/src/automerge/IrohNetworkAdapter.ts +37 -5
- package/src/domains/admin.ts +23 -0
- package/src/test.ts +0 -9
- package/src/playlistz/convert.ts +0 -267
- package/src/playlistz/index.ts +0 -6
- package/src/playlistz/mutations.ts +0 -181
- package/src/playlistz/protocol.ts +0 -222
- package/src/playlistz/schema.ts +0 -138
- package/src/playlistz/shareLink.ts +0 -76
- package/src/test/playlistz.ts +0 -631
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @freqhole/api-client
|
|
2
|
+
|
|
3
|
+
type-safe TypeScript client for freqhole APIs.
|
|
4
|
+
|
|
5
|
+
this package wraps generated route + schema metadata with a small runtime client so requests are validated and typed on both input and output.
|
|
6
|
+
|
|
7
|
+
## install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm i @freqhole/api-client
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
for local monorepo usage, this repo usually links it by file path.
|
|
14
|
+
|
|
15
|
+
## quick start
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createHttpClient } from "@freqhole/api-client";
|
|
19
|
+
|
|
20
|
+
const api = createHttpClient({
|
|
21
|
+
baseUrl: "http://localhost:4315",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const songs = await api.music.listSongs({
|
|
25
|
+
query: { limit: 25, offset: 0 },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (songs.success) {
|
|
29
|
+
console.log(songs.data.songs.length);
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## package structure
|
|
34
|
+
|
|
35
|
+
- `src/codegen/schema.ts`: generated zod schemas
|
|
36
|
+
- `src/codegen/routes.ts`: generated route metadata
|
|
37
|
+
- `src/*.ts`: hand-written helpers and client adapters
|
|
38
|
+
|
|
39
|
+
avoid editing files under `src/codegen/` directly.
|
|
40
|
+
|
|
41
|
+
## development notes
|
|
42
|
+
|
|
43
|
+
if you are working in this repo and need to regenerate types/routes, run codegen from `client-codegen/`.
|
|
44
|
+
|
|
45
|
+
the full generator workflow and architecture docs live in `client-codegen/README.md`.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
made with 💖 in NYC
|
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@freqhole/api-client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.32",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/freqhole/tomb"
|
|
8
|
+
},
|
|
5
9
|
"main": "./src/index.ts",
|
|
6
10
|
"publishConfig": {
|
|
7
11
|
"access": "public"
|
|
@@ -9,7 +13,6 @@
|
|
|
9
13
|
"exports": {
|
|
10
14
|
".": "./src/index.ts",
|
|
11
15
|
"./storage": "./src/storage/index.ts",
|
|
12
|
-
"./playlistz": "./src/playlistz/index.ts",
|
|
13
16
|
"./automerge": "./src/automerge/index.ts"
|
|
14
17
|
},
|
|
15
18
|
"scripts": {
|
|
@@ -77,6 +77,8 @@ export interface BiStreamLike {
|
|
|
77
77
|
*/
|
|
78
78
|
export interface MiddenStreamNode {
|
|
79
79
|
node_id(): string;
|
|
80
|
+
/** full serialized endpoint addr (node id + relay url) for deterministic dialing. */
|
|
81
|
+
node_addr?(): string;
|
|
80
82
|
open_bi(peer_addr: string, alpn: string): Promise<BiStreamLike>;
|
|
81
83
|
accept(): Promise<BiStreamLike | null>;
|
|
82
84
|
}
|
|
@@ -153,6 +155,14 @@ export class IrohNetworkAdapter extends NetworkAdapter {
|
|
|
153
155
|
{ attempt: number; timer: ReturnType<typeof setTimeout> | null }
|
|
154
156
|
>();
|
|
155
157
|
|
|
158
|
+
/**
|
|
159
|
+
* optional per-peer dial address hint keyed by node id. when present the
|
|
160
|
+
* adapter dials this full endpoint addr instead of the bare node id, which
|
|
161
|
+
* skips discovery (pkarr/dns) lookup. empty in normal operation; populated
|
|
162
|
+
* by callers that already know a peer's reachable addr.
|
|
163
|
+
*/
|
|
164
|
+
private peerDialAddr = new Map<string, string>();
|
|
165
|
+
|
|
156
166
|
/** listeners for connection state changes. */
|
|
157
167
|
private connectionStateListeners: Array<() => void> = [];
|
|
158
168
|
|
|
@@ -248,6 +258,7 @@ export class IrohNetworkAdapter extends NetworkAdapter {
|
|
|
248
258
|
this.readLoops.clear();
|
|
249
259
|
this.intendedPeers.clear();
|
|
250
260
|
this.failedPeers.clear();
|
|
261
|
+
this.peerDialAddr.clear();
|
|
251
262
|
this.alpnHandlers.clear();
|
|
252
263
|
|
|
253
264
|
// cancel all pending reconnection timers
|
|
@@ -278,7 +289,7 @@ export class IrohNetworkAdapter extends NetworkAdapter {
|
|
|
278
289
|
* ALPN, starts a read loop, and emits a peer-candidate event so
|
|
279
290
|
* automerge-repo begins syncing with this peer.
|
|
280
291
|
*/
|
|
281
|
-
async addPeer(nodeId: string): Promise<void> {
|
|
292
|
+
async addPeer(nodeId: string, dialAddr?: string): Promise<void> {
|
|
282
293
|
if (this._disconnected) {
|
|
283
294
|
throw new Error("adapter is disconnected");
|
|
284
295
|
}
|
|
@@ -286,6 +297,11 @@ export class IrohNetworkAdapter extends NetworkAdapter {
|
|
|
286
297
|
// track this peer as one we intend to stay connected to
|
|
287
298
|
this.intendedPeers.add(nodeId);
|
|
288
299
|
this.failedPeers.delete(nodeId);
|
|
300
|
+
// remember an explicit dial addr hint so dials (and reconnects) can skip
|
|
301
|
+
// discovery. keyed by node id; everything else stays keyed by node id too.
|
|
302
|
+
if (dialAddr) {
|
|
303
|
+
this.peerDialAddr.set(nodeId, dialAddr);
|
|
304
|
+
}
|
|
289
305
|
this.emitConnectionStateChange();
|
|
290
306
|
|
|
291
307
|
// clear any pending reconnection state - this is a fresh attempt
|
|
@@ -298,9 +314,22 @@ export class IrohNetworkAdapter extends NetworkAdapter {
|
|
|
298
314
|
}
|
|
299
315
|
|
|
300
316
|
const midden = await this.ensureMidden();
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
317
|
+
const target = this.peerDialAddr.get(nodeId) ?? nodeId;
|
|
318
|
+
try {
|
|
319
|
+
const stream = await midden.open_bi(target, this.syncAlpn);
|
|
320
|
+
this.registerStream(nodeId, stream);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
// the initial dial can fail when the peer is not yet discoverable. hand
|
|
323
|
+
// off to the background reconnect loop with backoff instead of forcing
|
|
324
|
+
// every caller to implement its own retry.
|
|
325
|
+
console.warn(
|
|
326
|
+
TAG,
|
|
327
|
+
"initial dial failed, scheduling reconnect:",
|
|
328
|
+
nodeId.slice(0, 16) + "...",
|
|
329
|
+
err
|
|
330
|
+
);
|
|
331
|
+
this.scheduleReconnect(nodeId);
|
|
332
|
+
}
|
|
304
333
|
}
|
|
305
334
|
|
|
306
335
|
/**
|
|
@@ -313,6 +342,7 @@ export class IrohNetworkAdapter extends NetworkAdapter {
|
|
|
313
342
|
// remove from intended set first so removePeer() won't schedule a reconnect
|
|
314
343
|
this.intendedPeers.delete(nodeId);
|
|
315
344
|
this.failedPeers.delete(nodeId);
|
|
345
|
+
this.peerDialAddr.delete(nodeId);
|
|
316
346
|
this.clearReconnectState(nodeId);
|
|
317
347
|
// delegate stream cleanup and peer-disconnected emission to removePeer
|
|
318
348
|
this.removePeer(nodeId);
|
|
@@ -643,6 +673,7 @@ export class IrohNetworkAdapter extends NetworkAdapter {
|
|
|
643
673
|
);
|
|
644
674
|
this.intendedPeers.delete(peerId);
|
|
645
675
|
this.failedPeers.add(peerId);
|
|
676
|
+
this.peerDialAddr.delete(peerId);
|
|
646
677
|
this.clearReconnectState(peerId);
|
|
647
678
|
this.emitConnectionStateChange();
|
|
648
679
|
return;
|
|
@@ -696,7 +727,8 @@ export class IrohNetworkAdapter extends NetworkAdapter {
|
|
|
696
727
|
|
|
697
728
|
try {
|
|
698
729
|
const midden = await this.ensureMidden();
|
|
699
|
-
const
|
|
730
|
+
const target = this.peerDialAddr.get(peerId) ?? peerId;
|
|
731
|
+
const stream = await midden.open_bi(target, this.syncAlpn);
|
|
700
732
|
console.log(TAG, "reconnected to peer:", peerId.slice(0, 16) + "...");
|
|
701
733
|
this.registerStream(peerId, stream);
|
|
702
734
|
// registerStream calls clearReconnectState, so no need to do it here
|
package/src/domains/admin.ts
CHANGED
|
@@ -93,5 +93,28 @@ export function createAdminMethods(call: CallFn) {
|
|
|
93
93
|
routes.admin.get_knock_status_public.path,
|
|
94
94
|
);
|
|
95
95
|
},
|
|
96
|
+
|
|
97
|
+
// internal service callbacks
|
|
98
|
+
deviceLinkedCallback: (params: s.DeviceLinkedCallbackRequest) => {
|
|
99
|
+
return call(
|
|
100
|
+
"admin", "device_linked_callback",
|
|
101
|
+
routes.admin.device_linked_callback.resp,
|
|
102
|
+
routes.admin.device_linked_callback.req,
|
|
103
|
+
routes.admin.device_linked_callback.method,
|
|
104
|
+
routes.admin.device_linked_callback.path,
|
|
105
|
+
params,
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
knockAcceptedCallback: (params: s.KnockAcceptedCallbackRequest) => {
|
|
110
|
+
return call(
|
|
111
|
+
"admin", "knock_accepted_callback",
|
|
112
|
+
routes.admin.knock_accepted_callback.resp,
|
|
113
|
+
routes.admin.knock_accepted_callback.req,
|
|
114
|
+
routes.admin.knock_accepted_callback.method,
|
|
115
|
+
routes.admin.knock_accepted_callback.path,
|
|
116
|
+
params,
|
|
117
|
+
);
|
|
118
|
+
},
|
|
96
119
|
};
|
|
97
120
|
}
|
package/src/test.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import { validateWrapperCoverage } from "./test/coverage.js";
|
|
3
3
|
import { runIntegrationTests } from "./test/integration.js";
|
|
4
4
|
import { runStatefulTests } from "./test/stateful.js";
|
|
5
|
-
import { runPlaylistzTests } from "./test/playlistz.js";
|
|
6
5
|
import { runAutomergeTests } from "./test/automerge.js";
|
|
7
6
|
import { runStorageTests } from "./test/storage.js";
|
|
8
7
|
|
|
@@ -22,14 +21,6 @@ async function main() {
|
|
|
22
21
|
console.log("=".repeat(50));
|
|
23
22
|
console.log("");
|
|
24
23
|
|
|
25
|
-
// always run playlistz domain tests (pure - no server needed)
|
|
26
|
-
const playlistzResults = await runPlaylistzTests();
|
|
27
|
-
totalPassed += playlistzResults.passed;
|
|
28
|
-
totalFailed += playlistzResults.failed;
|
|
29
|
-
|
|
30
|
-
console.log("=".repeat(50));
|
|
31
|
-
console.log("");
|
|
32
|
-
|
|
33
24
|
// automerge adapter unit tests (no server required)
|
|
34
25
|
const automergeResults = await runAutomergeTests();
|
|
35
26
|
totalPassed += automergeResults.passed;
|
package/src/playlistz/convert.ts
DELETED
|
@@ -1,267 +0,0 @@
|
|
|
1
|
-
// converters between external record shapes and PlaylistDoc.
|
|
2
|
-
// all converters are total: missing optional source fields become schema defaults.
|
|
3
|
-
// never throw on extra fields.
|
|
4
|
-
import type { Playlist as FreqholePlaylist, Song as FreqholeSong } from "../codegen/schema.js";
|
|
5
|
-
import {
|
|
6
|
-
emptyPlaylistDoc,
|
|
7
|
-
type ImageRef,
|
|
8
|
-
type PlaylistDoc,
|
|
9
|
-
type SongEntry,
|
|
10
|
-
type EntityUrl,
|
|
11
|
-
} from "./schema.js";
|
|
12
|
-
|
|
13
|
-
// ---- shared helpers ----
|
|
14
|
-
|
|
15
|
-
function tsToIso(ts: number): string {
|
|
16
|
-
return new Date(ts).toISOString();
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function mapEntityUrls(
|
|
20
|
-
urls?: Array<{ id?: string | null; name?: string | null; url: string }> | null,
|
|
21
|
-
): EntityUrl[] {
|
|
22
|
-
if (!urls) return [];
|
|
23
|
-
return urls.map((u) => ({
|
|
24
|
-
url: u.url,
|
|
25
|
-
...(u.id != null ? { id: u.id } : {}),
|
|
26
|
-
...(u.name != null ? { name: u.name } : {}),
|
|
27
|
-
}));
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// ---- legacy playlistz types ----
|
|
31
|
-
// (removed: playlistz has no deployed users, so there is no legacy idb data
|
|
32
|
-
// to convert - new playlists are created directly as docs)
|
|
33
|
-
|
|
34
|
-
// ---- spume idb types (inline - do not import from spume source) ----
|
|
35
|
-
// field shapes from tomb/client/spume/src/music/services/storage/types.ts
|
|
36
|
-
|
|
37
|
-
interface SpumeImageMetadata {
|
|
38
|
-
local_blob_id?: string;
|
|
39
|
-
remote_blob_id?: string;
|
|
40
|
-
is_primary: boolean;
|
|
41
|
-
blob_type: "thumbnail" | "waveform" | "original" | "preview";
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
interface SpumePlaylist {
|
|
45
|
-
playlist_id: string;
|
|
46
|
-
title: string;
|
|
47
|
-
description?: string | null;
|
|
48
|
-
is_public?: boolean;
|
|
49
|
-
images?: SpumeImageMetadata[];
|
|
50
|
-
urls?: Array<{ id?: string; name?: string; url: string }>;
|
|
51
|
-
created_at?: number;
|
|
52
|
-
updated_at?: number;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
interface SpumeSong {
|
|
56
|
-
id: string;
|
|
57
|
-
sha256: string;
|
|
58
|
-
title: string;
|
|
59
|
-
artist_name?: string;
|
|
60
|
-
album_title?: string;
|
|
61
|
-
duration_seconds?: number;
|
|
62
|
-
mime_type?: string | null;
|
|
63
|
-
file_size?: number | null;
|
|
64
|
-
blake3?: string | null;
|
|
65
|
-
lyrics?: string | null;
|
|
66
|
-
images?: SpumeImageMetadata[];
|
|
67
|
-
urls?: Array<{ id?: string; name?: string; url: string }>;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// map SpumeImageMetadata -> ImageRef, skipping entries with no blob id
|
|
71
|
-
function mapSpumeImages(images?: SpumeImageMetadata[]): ImageRef[] {
|
|
72
|
-
if (!images) return [];
|
|
73
|
-
const result: ImageRef[] = [];
|
|
74
|
-
for (const img of images) {
|
|
75
|
-
const blobId = img.local_blob_id ?? img.remote_blob_id;
|
|
76
|
-
if (!blobId) continue;
|
|
77
|
-
result.push({
|
|
78
|
-
blobId,
|
|
79
|
-
isPrimary: img.is_primary,
|
|
80
|
-
blobType: img.blob_type,
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
return result;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// map freqhole wire image (is_primary as 0/1 number) -> ImageRef
|
|
87
|
-
function mapFreqholeImages(
|
|
88
|
-
images?: Array<{
|
|
89
|
-
blob_id: string;
|
|
90
|
-
is_primary: number;
|
|
91
|
-
blob_type: "original" | "thumbnail" | "waveform" | "preview";
|
|
92
|
-
}> | null,
|
|
93
|
-
): ImageRef[] {
|
|
94
|
-
if (!images) return [];
|
|
95
|
-
return images.map((img) => ({
|
|
96
|
-
blobId: img.blob_id,
|
|
97
|
-
isPrimary: img.is_primary !== 0,
|
|
98
|
-
blobType: img.blob_type,
|
|
99
|
-
}));
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// ---- converters ----
|
|
103
|
-
|
|
104
|
-
// convert a freqhole wire Playlist + Song[] to a PlaylistDoc.
|
|
105
|
-
// maps snake_case fields and is_primary number (0/1) -> boolean.
|
|
106
|
-
// note: the freqhole Song wire type does not carry album_title; that field defaults to "".
|
|
107
|
-
// media_blob_id is used as sha256 (it is the server's short blob id, not a true content hash).
|
|
108
|
-
export function freqholePlaylistToDoc(
|
|
109
|
-
playlist: FreqholePlaylist,
|
|
110
|
-
songs: FreqholeSong[],
|
|
111
|
-
): PlaylistDoc {
|
|
112
|
-
const createdAt = tsToIso(playlist.created_at);
|
|
113
|
-
const lastModified = tsToIso(playlist.updated_at);
|
|
114
|
-
|
|
115
|
-
const songEntries: Record<string, SongEntry> = {};
|
|
116
|
-
const order: string[] = [];
|
|
117
|
-
|
|
118
|
-
for (const song of songs) {
|
|
119
|
-
const entry: SongEntry = {
|
|
120
|
-
id: song.id,
|
|
121
|
-
title: song.title ?? "",
|
|
122
|
-
artist: song.track_artist ?? "",
|
|
123
|
-
album: "",
|
|
124
|
-
duration: song.duration ?? 0,
|
|
125
|
-
mimeType: "",
|
|
126
|
-
fileSize: 0,
|
|
127
|
-
sha256: song.media_blob_id,
|
|
128
|
-
images: mapFreqholeImages(song.images),
|
|
129
|
-
urls: mapEntityUrls(song.urls),
|
|
130
|
-
...(song.lyrics != null ? { lyrics: song.lyrics } : {}),
|
|
131
|
-
};
|
|
132
|
-
songEntries[song.id] = entry;
|
|
133
|
-
order.push(song.id);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return emptyPlaylistDoc({
|
|
137
|
-
title: playlist.title ?? "",
|
|
138
|
-
description: playlist.description ?? "",
|
|
139
|
-
createdAt,
|
|
140
|
-
lastModified,
|
|
141
|
-
images: mapFreqholeImages(playlist.images),
|
|
142
|
-
urls: mapEntityUrls(playlist.urls),
|
|
143
|
-
songs: songEntries,
|
|
144
|
-
order,
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// convert a spume idb Playlist + Song[] to a PlaylistDoc.
|
|
149
|
-
// maps spume's ImageMetadata (local_blob_id/remote_blob_id) to ImageRef.
|
|
150
|
-
// spume Song fields: sha256, artist_name, album_title, duration_seconds, mime_type, file_size, blake3.
|
|
151
|
-
export function spumePlaylistToDoc(
|
|
152
|
-
playlist: SpumePlaylist,
|
|
153
|
-
songs: SpumeSong[],
|
|
154
|
-
): PlaylistDoc {
|
|
155
|
-
const now = new Date().toISOString();
|
|
156
|
-
const createdAt = playlist.created_at ? tsToIso(playlist.created_at) : now;
|
|
157
|
-
const lastModified = playlist.updated_at ? tsToIso(playlist.updated_at) : now;
|
|
158
|
-
|
|
159
|
-
const songEntries: Record<string, SongEntry> = {};
|
|
160
|
-
const order: string[] = [];
|
|
161
|
-
|
|
162
|
-
for (const song of songs) {
|
|
163
|
-
const entry: SongEntry = {
|
|
164
|
-
id: song.id,
|
|
165
|
-
title: song.title ?? "",
|
|
166
|
-
artist: song.artist_name ?? "",
|
|
167
|
-
album: song.album_title ?? "",
|
|
168
|
-
duration: song.duration_seconds ?? 0,
|
|
169
|
-
mimeType: song.mime_type ?? "",
|
|
170
|
-
fileSize: song.file_size ?? 0,
|
|
171
|
-
sha256: song.sha256,
|
|
172
|
-
images: mapSpumeImages(song.images),
|
|
173
|
-
urls: mapEntityUrls(song.urls),
|
|
174
|
-
...(song.blake3 != null ? { blake3: song.blake3 } : {}),
|
|
175
|
-
...(song.lyrics != null ? { lyrics: song.lyrics } : {}),
|
|
176
|
-
};
|
|
177
|
-
songEntries[song.id] = entry;
|
|
178
|
-
order.push(song.id);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return emptyPlaylistDoc({
|
|
182
|
-
title: playlist.title ?? "",
|
|
183
|
-
description: playlist.description ?? "",
|
|
184
|
-
createdAt,
|
|
185
|
-
lastModified,
|
|
186
|
-
images: mapSpumeImages(playlist.images),
|
|
187
|
-
urls: mapEntityUrls(playlist.urls),
|
|
188
|
-
songs: songEntries,
|
|
189
|
-
order,
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// convert a PlaylistDoc back to the freqhole wire format where lossless.
|
|
194
|
-
// id is not stored in the doc - defaults to "". caller must supply the real
|
|
195
|
-
// playlist id if needed (e.g. from the docIndex entry).
|
|
196
|
-
// note: album_title, mime_type, file_size are not in the freqhole Song wire type
|
|
197
|
-
// and are dropped. sha256 is mapped back to media_blob_id.
|
|
198
|
-
export function docToFreqholePlaylist(
|
|
199
|
-
doc: PlaylistDoc,
|
|
200
|
-
): { playlist: FreqholePlaylist; songs: FreqholeSong[] } {
|
|
201
|
-
const createdAt = doc.createdAt ? new Date(doc.createdAt).getTime() : 0;
|
|
202
|
-
const updatedAt = doc.lastModified ? new Date(doc.lastModified).getTime() : 0;
|
|
203
|
-
|
|
204
|
-
const playlist: FreqholePlaylist = {
|
|
205
|
-
id: "",
|
|
206
|
-
title: doc.title,
|
|
207
|
-
description: doc.description || null,
|
|
208
|
-
is_public: 0,
|
|
209
|
-
images: doc.images.map((img) => ({
|
|
210
|
-
blob_id: img.blobId,
|
|
211
|
-
is_primary: img.isPrimary ? 1 : 0,
|
|
212
|
-
blob_type: img.blobType,
|
|
213
|
-
})),
|
|
214
|
-
urls: doc.urls.map((u) => ({
|
|
215
|
-
url: u.url,
|
|
216
|
-
id: u.id ?? null,
|
|
217
|
-
name: u.name ?? null,
|
|
218
|
-
})),
|
|
219
|
-
created_by_id: null,
|
|
220
|
-
created_at: createdAt,
|
|
221
|
-
updated_at: updatedAt,
|
|
222
|
-
deleted_at: null,
|
|
223
|
-
deleted_by: null,
|
|
224
|
-
created_by: null,
|
|
225
|
-
updated_by: null,
|
|
226
|
-
song_count: doc.order.length,
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
const songs: FreqholeSong[] = doc.order
|
|
230
|
-
.map((id) => doc.songs[id])
|
|
231
|
-
.filter((s): s is SongEntry => s != null)
|
|
232
|
-
.map((s) => {
|
|
233
|
-
return {
|
|
234
|
-
id: s.id,
|
|
235
|
-
media_blob_id: s.sha256,
|
|
236
|
-
title: s.title,
|
|
237
|
-
track_number: 1,
|
|
238
|
-
disc_number: 1,
|
|
239
|
-
duration: s.duration,
|
|
240
|
-
track_artist: s.artist || null,
|
|
241
|
-
images: s.images.map((img) => ({
|
|
242
|
-
blob_id: img.blobId,
|
|
243
|
-
is_primary: img.isPrimary ? 1 : 0,
|
|
244
|
-
blob_type: img.blobType,
|
|
245
|
-
})),
|
|
246
|
-
urls: s.urls.map((u) => ({
|
|
247
|
-
url: u.url,
|
|
248
|
-
id: u.id ?? null,
|
|
249
|
-
name: u.name ?? null,
|
|
250
|
-
})),
|
|
251
|
-
metadata: null,
|
|
252
|
-
lyrics: s.lyrics ?? null,
|
|
253
|
-
created_at: createdAt,
|
|
254
|
-
updated_at: updatedAt,
|
|
255
|
-
deleted_at: null,
|
|
256
|
-
deleted_by: null,
|
|
257
|
-
created_by: null,
|
|
258
|
-
updated_by: null,
|
|
259
|
-
created_by_username: null,
|
|
260
|
-
updated_by_username: null,
|
|
261
|
-
bpm: null,
|
|
262
|
-
play_count: null,
|
|
263
|
-
};
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
return { playlist, songs };
|
|
267
|
-
}
|
package/src/playlistz/index.ts
DELETED
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
// pure mutation helpers for PlaylistDoc.
|
|
2
|
-
// each function mutates the doc draft in place - safe to call inside handle.change().
|
|
3
|
-
// no automerge imports: these are plain object mutations over plain objects.
|
|
4
|
-
import type { PlaylistDoc, SongEntry, ImageRef } from "./schema.js";
|
|
5
|
-
|
|
6
|
-
function nowIso(): string {
|
|
7
|
-
return new Date().toISOString();
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
// bump lastModified to now, optionally setting lastModifiedBy
|
|
11
|
-
function bumpModified(doc: PlaylistDoc, by?: string): void {
|
|
12
|
-
doc.lastModified = nowIso();
|
|
13
|
-
if (by !== undefined) {
|
|
14
|
-
doc.lastModifiedBy = by;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// insert or replace a song in the doc, appending its id to order if not already present
|
|
19
|
-
export function upsertSong(doc: PlaylistDoc, song: SongEntry, by?: string): void {
|
|
20
|
-
doc.songs[song.id] = song;
|
|
21
|
-
if (!doc.order.includes(song.id)) {
|
|
22
|
-
doc.order.push(song.id);
|
|
23
|
-
}
|
|
24
|
-
bumpModified(doc, by);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// remove a song from songs and order
|
|
28
|
-
export function removeSong(doc: PlaylistDoc, songId: string, by?: string): void {
|
|
29
|
-
delete doc.songs[songId];
|
|
30
|
-
const idx = doc.order.indexOf(songId);
|
|
31
|
-
if (idx !== -1) {
|
|
32
|
-
doc.order.splice(idx, 1);
|
|
33
|
-
}
|
|
34
|
-
bumpModified(doc, by);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// move a single song to a target index within order.
|
|
38
|
-
// single-item splice keeps automerge merge semantics sane.
|
|
39
|
-
export function reorderSongs(
|
|
40
|
-
doc: PlaylistDoc,
|
|
41
|
-
songId: string,
|
|
42
|
-
toIndex: number,
|
|
43
|
-
by?: string,
|
|
44
|
-
): void {
|
|
45
|
-
const fromIdx = doc.order.indexOf(songId);
|
|
46
|
-
if (fromIdx === -1) return;
|
|
47
|
-
doc.order.splice(fromIdx, 1);
|
|
48
|
-
const clamped = Math.max(0, Math.min(toIndex, doc.order.length));
|
|
49
|
-
doc.order.splice(clamped, 0, songId);
|
|
50
|
-
bumpModified(doc, by);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// playlist metadata fields settable via setMetadata
|
|
54
|
-
type MetadataFields = Partial<
|
|
55
|
-
Pick<
|
|
56
|
-
PlaylistDoc,
|
|
57
|
-
| "title"
|
|
58
|
-
| "description"
|
|
59
|
-
| "bgFilterEnabled"
|
|
60
|
-
| "bgFilterBlur"
|
|
61
|
-
| "bgFilterContrast"
|
|
62
|
-
| "bgFilterBrightness"
|
|
63
|
-
| "coverFilterEnabled"
|
|
64
|
-
| "coverFilterBlur"
|
|
65
|
-
| "bgSize"
|
|
66
|
-
| "bgPosition"
|
|
67
|
-
| "bgRepeat"
|
|
68
|
-
>
|
|
69
|
-
>;
|
|
70
|
-
|
|
71
|
-
// update playlist-level metadata fields
|
|
72
|
-
export function setMetadata(
|
|
73
|
-
doc: PlaylistDoc,
|
|
74
|
-
fields: MetadataFields,
|
|
75
|
-
by?: string,
|
|
76
|
-
): void {
|
|
77
|
-
if (fields.title !== undefined) doc.title = fields.title;
|
|
78
|
-
if (fields.description !== undefined) doc.description = fields.description;
|
|
79
|
-
for (const key of [
|
|
80
|
-
"bgFilterEnabled",
|
|
81
|
-
"bgFilterBlur",
|
|
82
|
-
"bgFilterContrast",
|
|
83
|
-
"bgFilterBrightness",
|
|
84
|
-
"coverFilterEnabled",
|
|
85
|
-
"coverFilterBlur",
|
|
86
|
-
"bgSize",
|
|
87
|
-
"bgPosition",
|
|
88
|
-
"bgRepeat",
|
|
89
|
-
] as const) {
|
|
90
|
-
if (fields[key] !== undefined) {
|
|
91
|
-
// assignment is type-safe per key; typescript can't narrow the loop var
|
|
92
|
-
(doc as Record<string, unknown>)[key] = fields[key];
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
bumpModified(doc, by);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// add an image ref to the playlist or to a specific song.
|
|
99
|
-
// if ref.isPrimary is true, clears isPrimary on all sibling images first.
|
|
100
|
-
export function addImage(
|
|
101
|
-
doc: PlaylistDoc,
|
|
102
|
-
ref: ImageRef,
|
|
103
|
-
target?: { songId?: string },
|
|
104
|
-
by?: string,
|
|
105
|
-
): void {
|
|
106
|
-
const images =
|
|
107
|
-
target?.songId != null
|
|
108
|
-
? doc.songs[target.songId]?.images
|
|
109
|
-
: doc.images;
|
|
110
|
-
|
|
111
|
-
if (!images) return;
|
|
112
|
-
|
|
113
|
-
if (ref.isPrimary) {
|
|
114
|
-
for (const img of images) {
|
|
115
|
-
img.isPrimary = false;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
images.push(ref);
|
|
119
|
-
bumpModified(doc, by);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// set the primary image by blobId, clearing isPrimary on all others.
|
|
123
|
-
// operates on playlist images or a specific song's images.
|
|
124
|
-
export function setPrimaryImage(
|
|
125
|
-
doc: PlaylistDoc,
|
|
126
|
-
blobId: string,
|
|
127
|
-
target?: { songId?: string },
|
|
128
|
-
by?: string,
|
|
129
|
-
): void {
|
|
130
|
-
const images =
|
|
131
|
-
target?.songId != null
|
|
132
|
-
? doc.songs[target.songId]?.images
|
|
133
|
-
: doc.images;
|
|
134
|
-
|
|
135
|
-
if (!images) return;
|
|
136
|
-
|
|
137
|
-
for (const img of images) {
|
|
138
|
-
img.isPrimary = img.blobId === blobId;
|
|
139
|
-
}
|
|
140
|
-
bumpModified(doc, by);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// record a peer joining this doc. idempotent - no-op if peer is already present.
|
|
144
|
-
// does not bump lastModified (peer bookkeeping is not a content edit).
|
|
145
|
-
export function addPeer(doc: PlaylistDoc, nodeId: string): void {
|
|
146
|
-
if (doc.peers[nodeId]) return;
|
|
147
|
-
doc.peers[nodeId] = {
|
|
148
|
-
nodeId,
|
|
149
|
-
joinedAt: nowIso(),
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// update lastSeenAt for an existing peer entry.
|
|
154
|
-
// does not bump lastModified (ephemeral presence, not a content edit).
|
|
155
|
-
export function stampLastSeen(doc: PlaylistDoc, nodeId: string): void {
|
|
156
|
-
const peer = doc.peers[nodeId];
|
|
157
|
-
if (peer) {
|
|
158
|
-
peer.lastSeenAt = nowIso();
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// set or update the access role for a node in the acl
|
|
163
|
-
export function setAclRole(
|
|
164
|
-
doc: PlaylistDoc,
|
|
165
|
-
nodeId: string,
|
|
166
|
-
role: "owner" | "editor" | "viewer",
|
|
167
|
-
by?: string,
|
|
168
|
-
): void {
|
|
169
|
-
if (!doc.acl) {
|
|
170
|
-
doc.acl = {};
|
|
171
|
-
}
|
|
172
|
-
doc.acl[nodeId] = { role };
|
|
173
|
-
bumpModified(doc, by);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// mark this playlist as deleted (tombstone).
|
|
177
|
-
// the doc remains in the repo; consumers check deleted before rendering.
|
|
178
|
-
export function tombstone(doc: PlaylistDoc, by?: string): void {
|
|
179
|
-
doc.deleted = true;
|
|
180
|
-
bumpModified(doc, by);
|
|
181
|
-
}
|