@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 +1 -1
- package/src/CharnelLocalTransport.ts +42 -106
- package/src/codegen/routes.ts +1 -0
- package/src/codegen/schema.ts +37 -2
- package/src/domains/video.ts +12 -0
- package/src/index.ts +1 -0
- package/src/permissions.ts +13 -3
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
18
|
-
|
|
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 =
|
|
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`)
|
|
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 =
|
|
369
|
+
const cached = this.blobPathCache.get(blobId);
|
|
373
370
|
if (cached && convertFileSrc) {
|
|
374
|
-
const url =
|
|
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
|
|
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
|
//
|
package/src/codegen/routes.ts
CHANGED
|
@@ -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 },
|
package/src/codegen/schema.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(),
|
package/src/domains/video.ts
CHANGED
|
@@ -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
package/src/permissions.ts
CHANGED
|
@@ -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);
|