@freqhole/api-client 0.3.2 → 0.3.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freqhole/api-client",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,29 +12,10 @@ type InvokeFn = (cmd: string, args?: unknown) => Promise<unknown>;
12
12
 
13
13
  // tauri invoke is dynamically imported to avoid bundling in browser builds
14
14
  let invoke: InvokeFn | null = null;
15
- let convertFileSrc: ((path: string) => string) | null = null;
16
-
17
- // webkitgtk (linux) can't play asset:// URLs in <audio> elements.
18
- // detect once at module level so we can use blob: URLs as a workaround.
19
- //
20
- // historically there was a second workaround here — an embedded http
21
- // loopback server (`media_server_info` ipc + `server::media_server`)
22
- // that served blobs via plain http. it has been removed in favor of
23
- // the rodio backend (see `client/spume/src/music/services/audio/`),
24
- // which bypasses the html `<audio>` element entirely on linux. when
25
- // rodio is *not* enabled and we're on linux, we fall back to the
26
- // blob: object url path below.
27
- const isLinuxWebKit = typeof navigator !== "undefined" && navigator.userAgent.includes("Linux");
28
-
29
- /**
30
- * blobs that must never be buffered into memory as a blob: URL, even on
31
- * linux: a local video can easily be multi-GB, and `fetch().arrayBuffer()`
32
- * on one hangs the whole app. these stream from asset:// instead, which
33
- * also gives `<video>` real range requests for seeking.
34
- */
35
- function isStreamableMime(mime?: string | null): boolean {
36
- return (mime ?? "").startsWith("video/");
37
- }
15
+ let convertFileSrc: ((path: string, protocol?: string) => string) | null = null;
16
+ // cached target_os check (android only, for now) - see mediaSrcFor() below.
17
+ // null = not yet determined, false = determined non-android.
18
+ let isAndroidCached: boolean | null = null;
38
19
 
39
20
  /**
40
21
  * initialize tauri invoke function
@@ -46,12 +27,45 @@ async function ensureInvoke(): Promise<InvokeFn> {
46
27
  invoke = tauri.invoke as InvokeFn;
47
28
  // also grab convertFileSrc for blob URLs
48
29
  convertFileSrc = tauri.convertFileSrc;
30
+ try {
31
+ const buildInfo = (await invoke("get_build_info")) as { target_os?: string };
32
+ isAndroidCached = buildInfo?.target_os === "android";
33
+ } catch {
34
+ isAndroidCached = false;
35
+ }
49
36
  return invoke;
50
37
  } catch {
51
38
  throw new Error("@tauri-apps/api not available - not running in Tauri");
52
39
  }
53
40
  }
54
41
 
42
+ /**
43
+ * build a playable url for a local file path - tauri's built-in `asset`
44
+ * protocol everywhere except android, which gets the custom
45
+ * `freqhole-media` protocol instead (see `client/charnel/src-tauri/src/
46
+ * media_protocol.rs`: the built-in one caps every range response to
47
+ * ~1MB, which android's webview media pipeline doesn't reliably recover
48
+ * from for files larger than that - confirmed via a live network trace).
49
+ * call `ensureInvoke()` (or anything that awaits it) at least once before
50
+ * calling this, so `isAndroidCached` is resolved.
51
+ */
52
+ function mediaSrcFor(path: string): string {
53
+ if (!convertFileSrc) {
54
+ throw new Error("convertFileSrc not available");
55
+ }
56
+ return isAndroidCached ? convertFileSrc(path, "freqhole-media") : convertFileSrc(path);
57
+ }
58
+
59
+ /**
60
+ * public, self-contained version of `mediaSrcFor` for callers outside this
61
+ * file (spume's `localAudio.ts`/`localVideo.ts`) - ensures tauri's invoke/
62
+ * convertFileSrc/target_os are loaded before resolving.
63
+ */
64
+ export async function resolveCharnelMediaSrc(path: string): Promise<string> {
65
+ await ensureInvoke();
66
+ return mediaSrcFor(path);
67
+ }
68
+
55
69
  /**
56
70
  * response shape from api_call command (matches GrimoireResponse)
57
71
  */
@@ -74,8 +88,6 @@ export class CharnelLocalTransport implements Transport {
74
88
  private blobPathCache = new Map<string, { path: string; mime?: string }>();
75
89
  // cache object URLs for db-stored blobs (no local path)
76
90
  private blobObjectUrlCache = new Map<string, string>();
77
- // single audio blob URL for linux workaround (revoke-on-replace to avoid memory leak)
78
- private audioBlobUrl: { blobId: string; url: string } | null = null;
79
91
 
80
92
  constructor(_baseUrl: string) {
81
93
  // baseUrl no longer needed - all requests go through IPC.
@@ -291,7 +303,7 @@ export class CharnelLocalTransport implements Transport {
291
303
  }
292
304
 
293
305
  // convert to asset URL and fetch via browser
294
- const assetUrl = convertFileSrc(pathInfo.path);
306
+ const assetUrl = mediaSrcFor(pathInfo.path);
295
307
  const fetchResponse = await fetch(assetUrl);
296
308
  const arrayBuffer = await fetchResponse.arrayBuffer();
297
309
 
@@ -339,13 +351,7 @@ export class CharnelLocalTransport implements Transport {
339
351
  /**
340
352
  * get blob URL — preference order:
341
353
  * 1. cached object URL (db-stored blobs)
342
- * 2. tauri asset:// (via `convertFileSrc`) on macos/windows
343
- * 3. linux fallback: fetch via asset:// and wrap in a blob: object URL
344
- * (webkitgtk can't stream asset:// into `<audio>`)
345
- *
346
- * the linux fallback buffers the ENTIRE file in memory, so it is never
347
- * used for video - a multi-GB local video would exhaust memory and hang
348
- * the app (`<video>` also seeks, which a one-shot blob can't stream).
354
+ * 2. tauri asset:// (via `convertFileSrc`), for any blob with a local path
349
355
  *
350
356
  * note: when the rodio audio backend is enabled (charnel + opt-in)
351
357
  * playback bypasses html `<audio>` entirely and reads files via
@@ -359,25 +365,15 @@ export class CharnelLocalTransport implements Transport {
359
365
  return cachedObjectUrl;
360
366
  }
361
367
 
362
- // on linux without rodio, we MUST go async to wrap in a blob:
363
- // url (asset:// can't stream into <audio> on webkitgtk) - except for
364
- // video, which streams from asset:// directly (see below).
365
- const cachedPath = this.blobPathCache.get(blobId);
366
- if (isLinuxWebKit && !isStreamableMime(cachedPath?.mime)) {
367
- console.debug(`[CharnelLocalTransport] blob ${blobId}: linux fallback (async)`);
368
- return this.getBlobUrlAsync(blobId);
369
- }
370
-
371
368
  // check path cache (filesystem blobs) — direct asset:// url
372
- const cached = cachedPath;
369
+ const cached = this.blobPathCache.get(blobId);
373
370
  if (cached && convertFileSrc) {
374
- const url = convertFileSrc(cached.path);
371
+ const url = mediaSrcFor(cached.path);
375
372
  console.debug(`[CharnelLocalTransport] blob ${blobId}: asset:// (cached) -> ${url}`);
376
373
  return url;
377
374
  }
378
375
 
379
376
  // need to fetch path (or data) first
380
- // console.debug(`[CharnelLocalTransport] blob ${blobId}: async path lookup`);
381
377
  return this.getBlobUrlAsync(blobId);
382
378
  }
383
379
 
@@ -400,22 +396,10 @@ export class CharnelLocalTransport implements Transport {
400
396
  throw new Error("convertFileSrc not available");
401
397
  }
402
398
 
403
- // on linux without media server: fall back to blob: workaround.
404
- // routes audio + image differently:
405
- // - audio: single-slot cache (revoke-on-replace) since audio
406
- // blobs are large and we only play one at a time
407
- // - other (images / waveforms / cover art): per-blob cache so
408
- // multiple `<img>` and css `background-image` urls coexist
409
- // across the playerbar, queue sidebar, etc.
410
- // video is excluded - buffering it would hang the app.
411
- if (isLinuxWebKit && !isStreamableMime(parsed.data.mime)) {
412
- return this.createBlobObjectUrl(blobId, parsed.data.path, parsed.data.mime);
413
- }
414
-
415
399
  console.debug(
416
400
  `[CharnelLocalTransport] blob ${blobId}: asset:// stream (mime=${parsed.data.mime ?? "?"})`,
417
401
  );
418
- return convertFileSrc(parsed.data.path);
402
+ return mediaSrcFor(parsed.data.path);
419
403
  }
420
404
  }
421
405
 
@@ -437,54 +421,6 @@ export class CharnelLocalTransport implements Transport {
437
421
  throw new Error(`failed to get blob path: ${response.body}`);
438
422
  }
439
423
 
440
- /**
441
- * create a blob: object URL by fetching via asset:// protocol.
442
- * used on linux where webkitgtk can't play asset:// in `<audio>`
443
- * elements (and historically also where the embedded http loopback
444
- * server stood in for the same workaround).
445
- *
446
- * mime-aware caching:
447
- * - `audio/*`: single-slot cache, revoke-on-replace. audio blobs
448
- * are large (often tens of MB) and only one ever plays at a
449
- * time, so leaking the rest is wasteful.
450
- * - everything else (images, waveforms, cover art): stored in
451
- * `blobObjectUrlCache` keyed by blob id so multiple `<img>` /
452
- * css `background-image` references coexist without one
453
- * revoking another.
454
- */
455
- private async createBlobObjectUrl(
456
- blobId: string,
457
- localPath: string,
458
- mime?: string,
459
- ): Promise<string> {
460
- if (!convertFileSrc) {
461
- throw new Error("convertFileSrc not available");
462
- }
463
-
464
- const isAudio = (mime ?? "").startsWith("audio/");
465
- const effectiveMime = mime ?? (isAudio ? "audio/mpeg" : "application/octet-stream");
466
-
467
- const assetUrl = convertFileSrc(localPath);
468
- const resp = await fetch(assetUrl);
469
- const arrayBuffer = await resp.arrayBuffer();
470
- const blob = new Blob([arrayBuffer], { type: effectiveMime });
471
- const objectUrl = URL.createObjectURL(blob);
472
-
473
- if (isAudio) {
474
- // revoke previous single-slot audio url (if any) so we don't
475
- // leak large buffers as the user moves between tracks.
476
- if (this.audioBlobUrl) {
477
- URL.revokeObjectURL(this.audioBlobUrl.url);
478
- }
479
- this.audioBlobUrl = { blobId, url: objectUrl };
480
- } else {
481
- // per-blob cache so e.g. the playerbar's waveform and the queue
482
- // sidebar's matching waveform share a single object url.
483
- this.blobObjectUrlCache.set(blobId, objectUrl);
484
- }
485
- return objectUrl;
486
- }
487
-
488
424
  // -----------------------------------------------------------------
489
425
  // job events (local ipc shortcut)
490
426
  //
@@ -261,6 +261,7 @@ export const routes = {
261
261
  query_video_series: { method: 'POST', path: '/api/video/series/query', req: s.QueryParamsSchema, resp: s.SeriesQueryResultSchema, auth: { type: 'authenticated' } as const },
262
262
  query_videos: { method: 'POST', path: '/api/video/videos/query', req: s.QueryVideosRequestSchema, resp: s.VideosQueryResultSchema, auth: { type: 'authenticated' } as const },
263
263
  recently_added_videos: { method: 'POST', path: '/api/video/relations/recently-added-videos', req: s.RecentlyAddedVideosRequestSchema, resp: s.RecentlyAddedVideosResponseSchema, auth: { type: 'authenticated' } as const },
264
+ reprocess_video: { method: 'POST', path: '/api/video/videos/reprocess', req: s.ReprocessVideoRequestSchema, resp: s.ReprocessVideoResultSchema, auth: { type: 'role', role: 'admin' } as const },
264
265
  sync_video_by_blake3: { method: 'POST', path: '/api/sync/video-by-blake3', req: s.SyncVideoByBlake3RequestSchema, resp: s.SyncVideoByBlake3ResponseSchema, auth: { type: 'role', role: 'member' } as const },
265
266
  unassigned_videos: { method: 'POST', path: '/api/video/relations/unassigned-videos', req: s.UnassignedVideosRequestSchema, resp: s.UnassignedVideosResponseSchema, auth: { type: 'authenticated' } as const },
266
267
  update_video_season: { method: 'POST', path: '/api/video/seasons/update', req: s.UpdateVideoSeasonRequestSchema, resp: s.VideoSeasonSchema, auth: { type: 'role', role: 'admin' } as const },
@@ -2340,6 +2340,8 @@ export const FavoriteItemSchema = z.union([z.intersection(z.object({
2340
2340
  title: z.string(),
2341
2341
  description: z.string().nullish(),
2342
2342
  is_public: z.number(),
2343
+ collaborative: z.number(),
2344
+ private: z.number(),
2343
2345
  images: z.array(z.object({
2344
2346
  blob_id: z.string(),
2345
2347
  is_primary: z.number(),
@@ -2357,6 +2359,7 @@ export const FavoriteItemSchema = z.union([z.intersection(z.object({
2357
2359
  deleted_by: z.string().nullish(),
2358
2360
  created_by: z.string().nullish(),
2359
2361
  updated_by: z.string().nullish(),
2362
+ created_by_username: z.string().nullish(),
2360
2363
  song_count: z.number()
2361
2364
  }),
2362
2365
  song_count: z.number(),
@@ -2420,6 +2423,8 @@ export const FavoritePlaylistResultSchema = z.object({
2420
2423
  title: z.string(),
2421
2424
  description: z.string().nullish(),
2422
2425
  is_public: z.number(),
2426
+ collaborative: z.number(),
2427
+ private: z.number(),
2423
2428
  images: z.array(z.object({
2424
2429
  blob_id: z.string(),
2425
2430
  is_primary: z.number(),
@@ -2437,6 +2442,7 @@ export const FavoritePlaylistResultSchema = z.object({
2437
2442
  deleted_by: z.string().nullish(),
2438
2443
  created_by: z.string().nullish(),
2439
2444
  updated_by: z.string().nullish(),
2445
+ created_by_username: z.string().nullish(),
2440
2446
  song_count: z.number()
2441
2447
  }),
2442
2448
  song_count: z.number(),
@@ -3630,6 +3636,8 @@ export const ListFavoritesResponseSchema = z.object({
3630
3636
  title: z.string(),
3631
3637
  description: z.string().nullish(),
3632
3638
  is_public: z.number(),
3639
+ collaborative: z.number(),
3640
+ private: z.number(),
3633
3641
  images: z.array(z.object({
3634
3642
  blob_id: z.string(),
3635
3643
  is_primary: z.number(),
@@ -3647,6 +3655,7 @@ export const ListFavoritesResponseSchema = z.object({
3647
3655
  deleted_by: z.string().nullish(),
3648
3656
  created_by: z.string().nullish(),
3649
3657
  updated_by: z.string().nullish(),
3658
+ created_by_username: z.string().nullish(),
3650
3659
  song_count: z.number()
3651
3660
  }),
3652
3661
  song_count: z.number(),
@@ -4628,6 +4637,8 @@ export const PlaylistSchema = z.object({
4628
4637
  title: z.string(),
4629
4638
  description: z.string().nullish(),
4630
4639
  is_public: z.number(),
4640
+ collaborative: z.number(),
4641
+ private: z.number(),
4631
4642
  images: z.array(z.object({
4632
4643
  blob_id: z.string(),
4633
4644
  is_primary: z.number(),
@@ -4645,6 +4656,7 @@ export const PlaylistSchema = z.object({
4645
4656
  deleted_by: z.string().nullish(),
4646
4657
  created_by: z.string().nullish(),
4647
4658
  updated_by: z.string().nullish(),
4659
+ created_by_username: z.string().nullish(),
4648
4660
  song_count: z.number()
4649
4661
  });
4650
4662
  export type Playlist = z.infer<typeof PlaylistSchema>;
@@ -4672,6 +4684,8 @@ export const PlaylistQueryResultSchema = z.object({
4672
4684
  title: z.string(),
4673
4685
  description: z.string().nullish(),
4674
4686
  is_public: z.number(),
4687
+ collaborative: z.number(),
4688
+ private: z.number(),
4675
4689
  images: z.array(z.object({
4676
4690
  blob_id: z.string(),
4677
4691
  is_primary: z.number(),
@@ -4689,6 +4703,7 @@ export const PlaylistQueryResultSchema = z.object({
4689
4703
  deleted_by: z.string().nullish(),
4690
4704
  created_by: z.string().nullish(),
4691
4705
  updated_by: z.string().nullish(),
4706
+ created_by_username: z.string().nullish(),
4692
4707
  song_count: z.number()
4693
4708
  }),
4694
4709
  song_count: z.number(),
@@ -5009,6 +5024,8 @@ export const PlaylistsQueryResultSchema = z.object({
5009
5024
  title: z.string(),
5010
5025
  description: z.string().nullish(),
5011
5026
  is_public: z.number(),
5027
+ collaborative: z.number(),
5028
+ private: z.number(),
5012
5029
  images: z.array(z.object({
5013
5030
  blob_id: z.string(),
5014
5031
  is_primary: z.number(),
@@ -5026,6 +5043,7 @@ export const PlaylistsQueryResultSchema = z.object({
5026
5043
  deleted_by: z.string().nullish(),
5027
5044
  created_by: z.string().nullish(),
5028
5045
  updated_by: z.string().nullish(),
5046
+ created_by_username: z.string().nullish(),
5029
5047
  song_count: z.number()
5030
5048
  }),
5031
5049
  song_count: z.number(),
@@ -5270,7 +5288,9 @@ export const QueryParamsSchema = z.object({
5270
5288
  min_rating: z.number().nullish(),
5271
5289
  mb_lookup_status: z.array(z.string()).nullish(),
5272
5290
  pending_review: z.boolean().nullish(),
5273
- caller_is_admin: z.boolean().nullish()
5291
+ own_or_collaborative_only: z.boolean().nullish(),
5292
+ caller_is_admin: z.boolean().nullish(),
5293
+ caller_user_id: z.string().nullish()
5274
5294
  });
5275
5295
  export type QueryParams = z.infer<typeof QueryParamsSchema>;
5276
5296
 
@@ -5320,7 +5340,9 @@ export const QueryVideosRequestSchema = z.object({
5320
5340
  min_rating: z.number().nullish(),
5321
5341
  mb_lookup_status: z.array(z.string()).nullish(),
5322
5342
  pending_review: z.boolean().nullish(),
5323
- caller_is_admin: z.boolean().nullish()
5343
+ own_or_collaborative_only: z.boolean().nullish(),
5344
+ caller_is_admin: z.boolean().nullish(),
5345
+ caller_user_id: z.string().nullish()
5324
5346
  }),
5325
5347
  series_id: z.string().nullish(),
5326
5348
  season_id: z.string().nullish(),
@@ -5887,6 +5909,17 @@ export const ReplaceAlbumsTagsRequestSchema = z.object({
5887
5909
  });
5888
5910
  export type ReplaceAlbumsTagsRequest = z.infer<typeof ReplaceAlbumsTagsRequestSchema>;
5889
5911
 
5912
+ export const ReprocessVideoRequestSchema = z.object({
5913
+ id: z.string()
5914
+ });
5915
+ export type ReprocessVideoRequest = z.infer<typeof ReprocessVideoRequestSchema>;
5916
+
5917
+ export const ReprocessVideoResultSchema = z.object({
5918
+ video_id: z.string(),
5919
+ job_id: z.string().nullish()
5920
+ });
5921
+ export type ReprocessVideoResult = z.infer<typeof ReprocessVideoResultSchema>;
5922
+
5890
5923
  export const RequeryEnrichmentRequestSchema = z.object({
5891
5924
  album_id: z.string(),
5892
5925
  source: z.union([z.literal('Mb'), z.literal('Lastfm'), z.literal('Audiodb')]),
@@ -7407,6 +7440,8 @@ export const UpdatePlaylistRequestSchema = z.object({
7407
7440
  title: z.string().nullish(),
7408
7441
  description: z.string().nullish(),
7409
7442
  is_public: z.boolean().nullish(),
7443
+ collaborative: z.boolean().nullish(),
7444
+ private: z.boolean().nullish(),
7410
7445
  entity_urls: z.array(z.object({
7411
7446
  id: z.string().nullish(),
7412
7447
  name: z.string().nullish(),
@@ -167,6 +167,18 @@ export function createVideoMethods(call: CallFn) {
167
167
  );
168
168
  },
169
169
 
170
+ reprocessVideo: (params: s.ReprocessVideoRequest) => {
171
+ return call(
172
+ "video",
173
+ "reprocess_video",
174
+ routes.video.reprocess_video.resp,
175
+ routes.video.reprocess_video.req,
176
+ routes.video.reprocess_video.method,
177
+ routes.video.reprocess_video.path,
178
+ params,
179
+ );
180
+ },
181
+
170
182
  // playback progress
171
183
  upsertPlaybackProgress: (params: s.UpsertPlaybackProgressRequest) => {
172
184
  return call(
package/src/index.ts CHANGED
@@ -31,6 +31,7 @@ export {
31
31
  CharnelLocalTransport,
32
32
  createCharnelLocalTransport,
33
33
  JobEventsStreamClosed,
34
+ resolveCharnelMediaSrc,
34
35
  } from "./CharnelLocalTransport.js";
35
36
  export type {
36
37
  MiddenNodeLike,
@@ -120,13 +120,17 @@ export function canUpdatePlaylist(
120
120
 
121
121
  /**
122
122
  * check if user can add songs to a playlist.
123
- * playlists use OwnerOr(Admin).
123
+ * playlists use OwnerOr(Admin) - collaborative playlists additionally
124
+ * allow any authenticated user, mirroring the backend's
125
+ * `require_owner_or_collaborative_or_scope`.
124
126
  */
125
127
  export function canAddSongsToPlaylist(
126
128
  userId: string,
127
129
  playlistOwnerId: string | null,
128
130
  userRole: UserRoleName,
131
+ isCollaborative = false,
129
132
  ): boolean {
133
+ if (isCollaborative) return true;
130
134
  const auth = routes.music.add_songs_to_playlist.auth;
131
135
  if (auth.type === "owner_or") {
132
136
  return canAccessOwnerOr(userId, playlistOwnerId, userRole, auth.role);
@@ -136,13 +140,16 @@ export function canAddSongsToPlaylist(
136
140
 
137
141
  /**
138
142
  * check if user can remove songs from a playlist.
139
- * playlists use OwnerOr(Admin).
143
+ * playlists use OwnerOr(Admin) - collaborative playlists additionally
144
+ * allow any authenticated user.
140
145
  */
141
146
  export function canRemoveSongsFromPlaylist(
142
147
  userId: string,
143
148
  playlistOwnerId: string | null,
144
149
  userRole: UserRoleName,
150
+ isCollaborative = false,
145
151
  ): boolean {
152
+ if (isCollaborative) return true;
146
153
  const auth = routes.music.remove_songs_from_playlist.auth;
147
154
  if (auth.type === "owner_or") {
148
155
  return canAccessOwnerOr(userId, playlistOwnerId, userRole, auth.role);
@@ -402,13 +409,16 @@ export function canAccessMusicBrainz(userRole: UserRoleName): boolean {
402
409
 
403
410
  /**
404
411
  * check if user can reorder songs in a playlist.
405
- * playlists use OwnerOr(Admin).
412
+ * playlists use OwnerOr(Admin) - collaborative playlists additionally
413
+ * allow any authenticated user.
406
414
  */
407
415
  export function canReorderPlaylistSongs(
408
416
  userId: string,
409
417
  playlistOwnerId: string | null,
410
418
  userRole: UserRoleName,
419
+ isCollaborative = false,
411
420
  ): boolean {
421
+ if (isCollaborative) return true;
412
422
  const auth = routes.music.reorder_playlist_songs.auth;
413
423
  if (auth.type === "owner_or") {
414
424
  return canAccessOwnerOr(userId, playlistOwnerId, userRole, auth.role);