@freqhole/api-client 0.2.15 → 0.3.1

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.
@@ -0,0 +1,199 @@
1
+ // tests for the blob cache policy.
2
+ //
3
+ // the transports back their blob fetches with the Cache API and, before
4
+ // `BlobFetchOptions` existed, wrote every blob they fetched unconditionally.
5
+ // that made the sync-to-local path store the same audio twice: once in the
6
+ // api cache (as a side effect of getting the bytes) and once in the library.
7
+ //
8
+ // `cache: "skip"` must suppress the write on *every* branch of the
9
+ // fallback chain, since which branch runs depends on what the peer supports.
10
+
11
+ import { beforeEach, describe, expect, it, vi } from "vitest";
12
+ import { WasmTransport, type MiddenNodeLike } from "./WasmTransport.js";
13
+
14
+ // minimal in-memory Cache API double. only the three methods the transports
15
+ // use (`open`, `match`, `put`) are implemented.
16
+ class FakeCache {
17
+ readonly entries = new Map<string, Response>();
18
+
19
+ async match(key: string): Promise<Response | undefined> {
20
+ return this.entries.get(key);
21
+ }
22
+
23
+ async put(key: string, response: Response): Promise<void> {
24
+ this.entries.set(key, response);
25
+ }
26
+ }
27
+
28
+ let fakeCache: FakeCache;
29
+
30
+ beforeEach(() => {
31
+ fakeCache = new FakeCache();
32
+ vi.stubGlobal("caches", {
33
+ open: async () => fakeCache,
34
+ });
35
+ // jsdom/node have no URL.createObjectURL
36
+ vi.stubGlobal("URL", {
37
+ ...URL,
38
+ createObjectURL: () => "blob:fake",
39
+ revokeObjectURL: () => {},
40
+ });
41
+ });
42
+
43
+ const BYTES = new Uint8Array([1, 2, 3, 4]);
44
+ const BLAKE3 = "a".repeat(64);
45
+
46
+ /** a node whose only working download path is the one named. lets each test
47
+ * force a specific branch of the fallback chain. */
48
+ function nodeWith(branch: "verified" | "api_request" | "by_id" | "legacy"): MiddenNodeLike {
49
+ const node: Partial<MiddenNodeLike> = {
50
+ node_id: () => "test-node",
51
+ api_request: async () => {
52
+ if (branch !== "api_request") throw new Error("api_request unavailable");
53
+ return {
54
+ status: 200,
55
+ body: JSON.stringify({
56
+ success: true,
57
+ data: { data: btoa(String.fromCharCode(...BYTES)), mime: "audio/mpeg" },
58
+ }),
59
+ };
60
+ },
61
+ };
62
+
63
+ if (branch === "verified") {
64
+ node.download_verified_with_ensure = async () => BYTES;
65
+ }
66
+ if (branch === "by_id") {
67
+ node.download_verified_by_id = async () => [BYTES, BLAKE3];
68
+ }
69
+ if (branch === "legacy") {
70
+ node.fetch_blob = async () => ({
71
+ data: () => BYTES,
72
+ content_type: () => "audio/mpeg",
73
+ size: () => BYTES.length,
74
+ });
75
+ }
76
+ return node as MiddenNodeLike;
77
+ }
78
+
79
+ const BRANCHES = ["verified", "api_request", "by_id", "legacy"] as const;
80
+
81
+ describe("WasmTransport.fetchBlob cache policy", () => {
82
+ describe("default (no options) still writes to the Cache API", () => {
83
+ for (const branch of BRANCHES) {
84
+ it(`writes on the ${branch} branch`, async () => {
85
+ const transport = new WasmTransport(nodeWith(branch), "peer");
86
+ // the verified branch is only tried when a blake3 is supplied, and
87
+ // by_id/legacy are only reached when it is not.
88
+ await transport.fetchBlob("blob-1", branch === "verified" ? BLAKE3 : undefined);
89
+ expect(fakeCache.entries.size).toBe(1);
90
+ });
91
+ }
92
+ });
93
+
94
+ describe('cache: "skip" suppresses the write', () => {
95
+ for (const branch of BRANCHES) {
96
+ it(`does not write on the ${branch} branch`, async () => {
97
+ const transport = new WasmTransport(nodeWith(branch), "peer");
98
+ await transport.fetchBlob("blob-1", branch === "verified" ? BLAKE3 : undefined, {
99
+ cache: "skip",
100
+ });
101
+ expect(fakeCache.entries.size).toBe(0);
102
+ });
103
+ }
104
+ });
105
+
106
+ it("still returns the bytes when the write is skipped", async () => {
107
+ const transport = new WasmTransport(nodeWith("verified"), "peer");
108
+ const result = await transport.fetchBlob("blob-1", BLAKE3, { cache: "skip" });
109
+ expect(Array.from(result.data)).toEqual(Array.from(BYTES));
110
+ });
111
+
112
+ it('reads an existing cache entry even with cache: "skip"', async () => {
113
+ // "skip" suppresses writes only - bytes already cached are still served,
114
+ // so a sync does not re-fetch something already sitting in the cache.
115
+ await fakeCache.put(
116
+ "https://blob.local/blob-1",
117
+ new Response(BYTES, { headers: { "Content-Type": "audio/mpeg" } }),
118
+ );
119
+ const node = nodeWith("verified");
120
+ const spy = vi.fn(async () => BYTES);
121
+ node.download_verified_with_ensure = spy;
122
+
123
+ const transport = new WasmTransport(node, "peer");
124
+ const result = await transport.fetchBlob("blob-1", BLAKE3, { cache: "skip" });
125
+
126
+ expect(Array.from(result.data)).toEqual(Array.from(BYTES));
127
+ expect(spy).not.toHaveBeenCalled();
128
+ });
129
+ });
130
+
131
+ describe("WasmTransport.fetchBlobWithProgress cache policy", () => {
132
+ describe("default (no options) still writes to the Cache API", () => {
133
+ for (const branch of BRANCHES) {
134
+ it(`writes on the ${branch} branch`, async () => {
135
+ const transport = new WasmTransport(nodeWith(branch), "peer");
136
+ await transport.fetchBlobWithProgress(
137
+ "blob-1",
138
+ () => {},
139
+ branch === "verified" ? BLAKE3 : undefined,
140
+ );
141
+ expect(fakeCache.entries.size).toBe(1);
142
+ });
143
+ }
144
+ });
145
+
146
+ describe('cache: "skip" suppresses the write', () => {
147
+ for (const branch of BRANCHES) {
148
+ it(`does not write on the ${branch} branch`, async () => {
149
+ const transport = new WasmTransport(nodeWith(branch), "peer");
150
+ await transport.fetchBlobWithProgress(
151
+ "blob-1",
152
+ () => {},
153
+ branch === "verified" ? BLAKE3 : undefined,
154
+ undefined,
155
+ undefined,
156
+ { cache: "skip" },
157
+ );
158
+ expect(fakeCache.entries.size).toBe(0);
159
+ });
160
+ }
161
+ });
162
+
163
+ it("still reports progress when the write is skipped", async () => {
164
+ const transport = new WasmTransport(nodeWith("verified"), "peer");
165
+ const onProgress = vi.fn();
166
+ await transport.fetchBlobWithProgress("blob-1", onProgress, BLAKE3, undefined, undefined, {
167
+ cache: "skip",
168
+ });
169
+ expect(onProgress).toHaveBeenCalled();
170
+ });
171
+ });
172
+
173
+ describe("WasmTransport.getBlobUrl* cache policy", () => {
174
+ it('getBlobUrl with cache: "skip" does not write', async () => {
175
+ const transport = new WasmTransport(nodeWith("verified"), "peer");
176
+ await transport.getBlobUrl("blob-1", BLAKE3, { cache: "skip" });
177
+ expect(fakeCache.entries.size).toBe(0);
178
+ });
179
+
180
+ it("getBlobUrl writes by default", async () => {
181
+ const transport = new WasmTransport(nodeWith("verified"), "peer");
182
+ await transport.getBlobUrl("blob-1", BLAKE3);
183
+ expect(fakeCache.entries.size).toBe(1);
184
+ });
185
+
186
+ it('getBlobUrlWithProgress with cache: "skip" does not write', async () => {
187
+ const transport = new WasmTransport(nodeWith("verified"), "peer");
188
+ await transport.getBlobUrlWithProgress("blob-1", () => {}, BLAKE3, undefined, undefined, {
189
+ cache: "skip",
190
+ });
191
+ expect(fakeCache.entries.size).toBe(0);
192
+ });
193
+
194
+ it("getBlobUrlWithProgress writes by default", async () => {
195
+ const transport = new WasmTransport(nodeWith("verified"), "peer");
196
+ await transport.getBlobUrlWithProgress("blob-1", () => {}, BLAKE3);
197
+ expect(fakeCache.entries.size).toBe(1);
198
+ });
199
+ });
@@ -61,6 +61,31 @@ export const routes = {
61
61
  update_username: { method: 'POST', path: '/api/auth/profile/update-username', req: s.UpdateUsernameRequestSchema, resp: s.WhoAmIResponseSchema, auth: { type: 'authenticated' } as const },
62
62
  whoami: { method: 'GET', path: '/api/auth/whoami', req: null, resp: s.WhoAmIResponseSchema, auth: { type: 'authenticated' } as const },
63
63
  },
64
+ entities: {
65
+ add_entities_tags: { method: 'POST', path: '/api/entities/tags/add', req: s.AddEntitiesTagsRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'role', role: 'admin' } as const },
66
+ add_entity_taxon: { method: 'POST', path: '/api/entities/taxons/add', req: s.AddEntityTaxonRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'role', role: 'admin' } as const },
67
+ add_entity_url: { method: 'POST', path: '/api/entities/urls/add', req: s.AddEntityUrlRequestSchema, resp: s.EntityUrlSchema, auth: { type: 'role', role: 'admin' } as const },
68
+ add_playlist_item: { method: 'POST', path: '/api/entities/playlists/items/add', req: s.AddPlaylistItemRequestSchema, resp: s.PlaylistItemSchema, auth: { type: 'owner_or', role: 'admin' } as const },
69
+ add_playlist_items: { method: 'POST', path: '/api/entities/playlists/items/add-bulk', req: s.AddPlaylistItemsRequestSchema, resp: s.PlaylistItemSchema.array(), auth: { type: 'owner_or', role: 'admin' } as const },
70
+ get_entities_tags: { method: 'POST', path: '/api/entities/tags/get', req: s.GetEntitiesTagsRequestSchema, resp: s.EntityTagCountSchema.array(), auth: { type: 'authenticated' } as const },
71
+ get_entity_images: { method: 'POST', path: '/api/entities/images/get', req: s.GetEntityImagesRequestSchema, resp: s.ImageMetadataSchema.array(), auth: { type: 'authenticated' } as const },
72
+ get_entity_taxons: { method: 'POST', path: '/api/entities/taxons/get', req: s.GetEntityTaxonsRequestSchema, resp: s.EntityTaxonLinkSchema.array(), auth: { type: 'authenticated' } as const },
73
+ get_entity_urls: { method: 'POST', path: '/api/entities/urls/get', req: s.GetEntityUrlsRequestSchema, resp: s.EntityUrlSchema.array(), auth: { type: 'authenticated' } as const },
74
+ get_favorite_status_bulk: { method: 'POST', path: '/api/entities/favorites/status-bulk', req: s.GetFavoriteStatusBulkRequestSchema, resp: s.FavoriteStatusItemSchema.array(), auth: { type: 'authenticated' } as const },
75
+ get_rating_stats: { method: 'POST', path: '/api/entities/ratings/stats', req: s.GetRatingStatsRequestSchema, resp: s.RatingStatsSchema, auth: { type: 'authenticated' } as const },
76
+ get_rating_status_bulk: { method: 'POST', path: '/api/entities/ratings/status-bulk', req: s.GetRatingStatusBulkRequestSchema, resp: s.RatingStatusItemSchema.array(), auth: { type: 'authenticated' } as const },
77
+ list_entity_type_tags: { method: 'POST', path: '/api/entities/tags/list-by-type', req: s.ListEntityTypeTagsRequestSchema, resp: s.EntityTagCountSchema.array(), auth: { type: 'authenticated' } as const },
78
+ list_playlist_items: { method: 'POST', path: '/api/entities/playlists/items/list', req: s.ListPlaylistItemsRequestSchema, resp: s.PlaylistItemSchema.array(), auth: { type: 'authenticated' } as const },
79
+ remove_entities_tags: { method: 'POST', path: '/api/entities/tags/remove', req: s.RemoveEntitiesTagsRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'role', role: 'admin' } as const },
80
+ remove_entity_taxon: { method: 'POST', path: '/api/entities/taxons/remove', req: s.RemoveEntityTaxonRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'role', role: 'admin' } as const },
81
+ remove_entity_url: { method: 'POST', path: '/api/entities/urls/remove', req: s.RemoveEntityUrlRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'role', role: 'admin' } as const },
82
+ remove_playlist_item: { method: 'POST', path: '/api/entities/playlists/items/remove', req: s.RemovePlaylistItemRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'owner_or', role: 'admin' } as const },
83
+ remove_playlist_items: { method: 'POST', path: '/api/entities/playlists/items/remove-bulk', req: s.RemovePlaylistItemsRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'owner_or', role: 'admin' } as const },
84
+ remove_rating: { method: 'POST', path: '/api/entities/ratings/remove', req: s.RemoveRatingRequestSchema, resp: s.RemoveRatingResponseSchema, auth: { type: 'role', role: 'member' } as const },
85
+ reorder_playlist_items: { method: 'POST', path: '/api/entities/playlists/items/reorder', req: s.ReorderPlaylistItemsRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'owner_or', role: 'admin' } as const },
86
+ set_favorite: { method: 'POST', path: '/api/entities/favorites/set', req: s.SetFavoriteRequestSchema, resp: s.SetFavoriteResponseSchema, auth: { type: 'role', role: 'member' } as const },
87
+ set_rating: { method: 'POST', path: '/api/entities/ratings/set', req: s.SetRatingRequestSchema, resp: s.SetRatingResponseSchema, auth: { type: 'role', role: 'member' } as const },
88
+ },
64
89
  music: {
65
90
  activity_feed: { method: 'POST', path: '/api/analytics/feed', req: s.FeedRequestSchema, resp: s.FeedResponseSchema, auth: { type: 'authenticated' } as const },
66
91
  add_album_taxon: { method: 'POST', path: '/api/taxonomy/album-links/add', req: s.AddAlbumTaxonRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'role', role: 'admin' } as const },
@@ -84,7 +109,7 @@ export const routes = {
84
109
  confirm_mb_match: { method: 'POST', path: '/api/albums/mb-confirm', req: s.ConfirmMbMatchRequestSchema, resp: s.MbMatchActionResponseSchema, auth: { type: 'role', role: 'admin' } as const },
85
110
  create_artist: { method: 'POST', path: '/api/music/artists', req: s.CreateArtistRequestSchema, resp: s.ArtistSchema, auth: { type: 'role', role: 'admin' } as const },
86
111
  create_fetch_job: { method: 'POST', path: '/api/music/fetch', req: s.FetchMediaParamsSchema, resp: s.JobResponseSchema, auth: { type: 'role', role: 'member' } as const },
87
- create_listen_session: { method: 'POST', path: '/api/analytics/sessions', req: s.CreateListenSessionRequestSchema, resp: s.ListenSessionSchema, auth: { type: 'role', role: 'member' } as const },
112
+ create_playback_session: { method: 'POST', path: '/api/analytics/sessions', req: s.CreatePlaybackSessionRequestSchema, resp: s.PlaybackSessionSchema, auth: { type: 'authenticated' } as const },
88
113
  create_playlist: { method: 'POST', path: '/api/music/playlists', req: s.CreatePlaylistRequestSchema, resp: s.PlaylistSchema, auth: { type: 'role', role: 'member' } as const },
89
114
  create_precheck_fetch_job: { method: 'POST', path: '/api/music/fetch/precheck', req: s.PreCheckFetchParamsSchema, resp: s.JobResponseSchema, auth: { type: 'role', role: 'member' } as const },
90
115
  create_taxon: { method: 'POST', path: '/api/taxonomy/taxons/create', req: s.CreateTaxonRequestSchema, resp: s.TaxonSchema, auth: { type: 'role', role: 'admin' } as const },
@@ -93,7 +118,7 @@ export const routes = {
93
118
  delete_artist: { method: 'POST', path: '/api/artists/delete', req: s.DeleteArtistRequestSchema, resp: s.DeleteArtistResponseSchema, auth: { type: 'role', role: 'admin' } as const },
94
119
  delete_feed_event: { method: 'POST', path: '/api/analytics/feed/delete', req: s.DeleteFeedEventRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'authenticated' } as const },
95
120
  delete_image: { method: 'POST', path: '/api/music/images/delete', req: s.DeleteImageRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'role', role: 'admin' } as const },
96
- delete_listen_session: { method: 'POST', path: '/api/analytics/sessions/delete', req: s.DeleteListenSessionRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'owner_or', role: 'admin' } as const },
121
+ delete_playback_session: { method: 'POST', path: '/api/analytics/sessions/delete', req: s.DeletePlaybackSessionRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'owner_or', role: 'admin' } as const },
97
122
  delete_playlist: { method: 'POST', path: '/api/playlists/delete', req: s.DeletePlaylistRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'owner_or', role: 'admin' } as const },
98
123
  delete_song: { method: 'POST', path: '/api/songs/delete', req: s.DeleteSongRequestSchema, resp: s.DeleteSongResponseSchema, auth: { type: 'role', role: 'admin' } as const },
99
124
  delete_tag: { method: 'POST', path: '/api/tags/delete', req: s.DeleteTagRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'role', role: 'admin' } as const },
@@ -118,12 +143,11 @@ export const routes = {
118
143
  get_enrichment_progress: { method: 'POST', path: '/api/music/albums/enrichment/progress', req: s.GetEnrichmentProgressRequestSchema, resp: s.GetEnrichmentProgressResponseSchema, auth: { type: 'role', role: 'admin' } as const },
119
144
  get_fetch_job: { method: 'POST', path: '/api/music/fetch/status', req: s.GetJobRequestSchema, resp: s.JobResponseSchema, auth: { type: 'authenticated' } as const },
120
145
  get_job_status: { method: 'POST', path: '/api/jobs/status', req: s.GetJobsStatusRequestSchema, resp: s.GetJobsStatusResponseSchema, auth: { type: 'authenticated' } as const },
121
- get_listen_session: { method: 'POST', path: '/api/analytics/sessions/get', req: s.GetListenSessionRequestSchema, resp: s.ListenSessionSchema, auth: { type: 'authenticated' } as const },
122
146
  get_musicbrainz_release: { method: 'POST', path: '/api/musicbrainz/release', req: s.GetReleaseRequestSchema, resp: s.MbReleaseDetailSchema, auth: { type: 'role', role: 'admin' } as const },
147
+ get_playback_session: { method: 'POST', path: '/api/analytics/sessions/get', req: s.GetPlaybackSessionRequestSchema, resp: s.PlaybackSessionSchema, auth: { type: 'authenticated' } as const },
123
148
  get_playlist_by_id: { method: 'POST', path: '/api/music/playlists/get', req: s.GetPlaylistRequestSchema, resp: s.PlaylistSchema, auth: { type: 'authenticated' } as const },
124
149
  get_playlist_etag: { method: 'POST', path: '/api/music/playlists/etag', req: s.GetPlaylistRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'authenticated' } as const },
125
150
  get_playlist_images: { method: 'POST', path: '/api/playlists/images', req: s.GetPlaylistRequestSchema, resp: z.string().array(), auth: { type: 'authenticated' } as const },
126
- get_rating_stats: { method: 'POST', path: '/api/ratings/stats', req: s.GetRatingStatsRequestSchema, resp: s.RatingStatsSchema, auth: { type: 'authenticated' } as const },
127
151
  get_tag: { method: 'POST', path: '/api/tags/get', req: s.GetTagRequestSchema, resp: s.TagSchema, auth: { type: 'authenticated' } as const },
128
152
  get_taxon: { method: 'POST', path: '/api/taxonomy/taxons/get', req: s.GetTaxonRequestSchema, resp: s.TaxonSchema, auth: { type: 'authenticated' } as const },
129
153
  get_taxon_ancestors: { method: 'POST', path: '/api/taxonomy/taxons/ancestors', req: s.GetTaxonRequestSchema, resp: s.TaxonRefSchema.array(), auth: { type: 'authenticated' } as const },
@@ -135,13 +159,13 @@ export const routes = {
135
159
  list_beloved: { method: 'POST', path: '/api/favorites/beloved', req: s.ListBelovedRequestSchema, resp: s.ListBelovedResponseSchema, auth: { type: 'authenticated' } as const },
136
160
  list_favorites: { method: 'POST', path: '/api/favorites/list', req: s.ListFavoritesRequestSchema, resp: s.ListFavoritesResponseSchema, auth: { type: 'role', role: 'member' } as const },
137
161
  list_jobs: { method: 'POST', path: '/api/jobs/list', req: s.ListJobsRequestSchema, resp: s.JobResponseSchema.array(), auth: { type: 'authenticated' } as const },
138
- list_listen_sessions: { method: 'POST', path: '/api/analytics/sessions/list', req: s.ListListenSessionsRequestSchema, resp: s.ListListenSessionsResponseSchema, auth: { type: 'authenticated' } as const },
139
162
  list_pending_import_review: { method: 'POST', path: '/api/music/import/pending', req: s.ListPendingReviewRequestSchema, resp: s.PendingReviewSessionSchema.array(), auth: { type: 'authenticated' } as const },
163
+ list_playback_sessions: { method: 'POST', path: '/api/analytics/sessions/list', req: s.ListPlaybackSessionsRequestSchema, resp: s.ListPlaybackSessionsResponseSchema, auth: { type: 'authenticated' } as const },
140
164
  list_playlists: { method: 'POST', path: '/api/music/playlists/list', req: s.QueryParamsSchema, resp: s.PlaylistsQueryResultSchema, auth: { type: 'authenticated' } as const },
141
165
  list_related_artists: { method: 'POST', path: '/api/related-artists/list', req: s.ListRelatedArtistsRequestSchema, resp: s.ListRelatedArtistsResponseSchema, auth: { type: 'authenticated' } as const },
142
166
  list_related_artists_batch: { method: 'POST', path: '/api/related-artists/list-batch', req: s.ListRelatedArtistsBatchRequestSchema, resp: s.ListRelatedArtistsBatchResponseSchema, auth: { type: 'authenticated' } as const },
143
167
  list_tags: { method: 'GET', path: '/api/tags/list', req: null, resp: s.TagSchema.array(), auth: { type: 'authenticated' } as const },
144
- list_taxon_kinds: { method: 'POST', path: '/api/taxonomy/kinds/list', req: null, resp: s.TaxonKindSchema.array(), auth: { type: 'authenticated' } as const },
168
+ list_taxon_kinds: { method: 'POST', path: '/api/taxonomy/kinds/list', req: s.ListTaxonKindsRequestSchema, resp: s.TaxonKindSchema.array(), auth: { type: 'authenticated' } as const },
145
169
  list_taxon_parents_for_kind: { method: 'POST', path: '/api/taxonomy/parents/list-by-kind', req: s.ListTaxonParentsForKindRequestSchema, resp: s.TaxonParentEdgeSchema.array(), auth: { type: 'authenticated' } as const },
146
170
  list_taxons_by_kind: { method: 'POST', path: '/api/taxonomy/taxons/list-by-kind', req: s.ListTaxonsByKindRequestSchema, resp: s.TaxonSchema.array(), auth: { type: 'authenticated' } as const },
147
171
  listening_history: { method: 'POST', path: '/api/analytics/listening-history', req: s.ListeningHistoryRequestSchema, resp: s.ListeningHistoryResponseSchema, auth: { type: 'authenticated' } as const },
@@ -163,12 +187,12 @@ export const routes = {
163
187
  query_taxons: { method: 'POST', path: '/api/taxonomy/taxons/query', req: s.QueryTaxonsRequestSchema, resp: s.TaxonsQueryResultSchema, auth: { type: 'authenticated' } as const },
164
188
  recent_songs: { method: 'POST', path: '/api/songs/recent', req: s.RecentSongsRequestSchema, resp: s.SongSchema.array(), auth: { type: 'authenticated' } as const },
165
189
  recently_added_albums: { method: 'POST', path: '/api/music/relations/recently-added-albums', req: s.RecentlyAddedAlbumsRequestSchema, resp: s.RecentlyAddedAlbumsResponseSchema, auth: { type: 'authenticated' } as const },
166
- record_play: { method: 'POST', path: '/api/analytics/play', req: s.RecordPlayRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'role', role: 'member' } as const },
190
+ record_play: { method: 'POST', path: '/api/analytics/play', req: s.RecordPlayRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'authenticated' } as const },
167
191
  record_playlist_play: { method: 'POST', path: '/api/playlists/record-play', req: s.GetPlaylistRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'authenticated' } as const },
192
+ record_video_play: { method: 'POST', path: '/api/analytics/video-play', req: s.RecordVideoPlayRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'authenticated' } as const },
168
193
  reject_mb_match: { method: 'POST', path: '/api/albums/mb-reject', req: s.RejectMbMatchRequestSchema, resp: s.MbMatchActionResponseSchema, auth: { type: 'role', role: 'admin' } as const },
169
194
  remove_album_taxon: { method: 'POST', path: '/api/taxonomy/album-links/remove', req: s.RemoveAlbumTaxonRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'role', role: 'admin' } as const },
170
195
  remove_albums_tags: { method: 'POST', path: '/api/tags/albums/remove', req: s.RemoveAlbumsTagsRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'role', role: 'admin' } as const },
171
- remove_rating: { method: 'POST', path: '/api/ratings/remove', req: s.RemoveRatingRequestSchema, resp: s.RemoveRatingResponseSchema, auth: { type: 'role', role: 'member' } as const },
172
196
  remove_songs_from_playlist: { method: 'POST', path: '/api/playlists/remove-songs', req: s.RemoveSongsFromPlaylistRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'owner_or', role: 'admin' } as const },
173
197
  remove_taxon_parent: { method: 'POST', path: '/api/taxonomy/parents/remove', req: s.RemoveTaxonParentRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'role', role: 'admin' } as const },
174
198
  reorder_playlist_songs: { method: 'POST', path: '/api/playlists/reorder', req: s.ReorderPlaylistSongsRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'owner_or', role: 'admin' } as const },
@@ -177,10 +201,8 @@ export const routes = {
177
201
  search: { method: 'POST', path: '/api/music/search', req: s.SearchRequestSchema, resp: s.SearchResponseSchema, auth: { type: 'authenticated' } as const },
178
202
  search_musicbrainz_releases: { method: 'POST', path: '/api/musicbrainz/search/releases', req: s.SearchReleasesRequestSchema, resp: s.MbSearchReleasesResponseSchema, auth: { type: 'role', role: 'admin' } as const },
179
203
  set_album_taxons: { method: 'POST', path: '/api/taxonomy/album-links/set', req: s.SetAlbumTaxonsRequestSchema, resp: s.AlbumTaxonLinkSchema.array(), auth: { type: 'role', role: 'admin' } as const },
180
- set_favorite: { method: 'POST', path: '/api/favorites/set', req: s.SetFavoriteRequestSchema, resp: s.SetFavoriteResponseSchema, auth: { type: 'role', role: 'member' } as const },
181
204
  set_mb_lookup_status: { method: 'POST', path: '/api/albums/set-mb-lookup-status', req: s.SetMbLookupStatusRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'role', role: 'admin' } as const },
182
205
  set_primary_image: { method: 'POST', path: '/api/music/images/set-primary', req: s.SetPrimaryImageRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'role', role: 'admin' } as const },
183
- set_rating: { method: 'POST', path: '/api/ratings/set', req: s.SetRatingRequestSchema, resp: s.SetRatingResponseSchema, auth: { type: 'role', role: 'member' } as const },
184
206
  set_related_artist_bandcamp: { method: 'POST', path: '/api/related-artists/set-bandcamp', req: s.SetRelatedArtistBandcampRequestSchema, resp: s.RelatedArtistApiSchema, auth: { type: 'role', role: 'admin' } as const },
185
207
  set_scalar_attribute: { method: 'POST', path: '/api/taxonomy/scalars/set', req: s.SetScalarAttributeRequestSchema, resp: s.ScalarAttributeSchema, auth: { type: 'role', role: 'admin' } as const },
186
208
  set_taxon_color: { method: 'POST', path: '/api/taxonomy/taxons/set-color', req: s.SetTaxonColorRequestSchema, resp: s.TaxonSchema, auth: { type: 'role', role: 'admin' } as const },
@@ -201,13 +223,53 @@ export const routes = {
201
223
  update_album: { method: 'POST', path: '/api/albums/update', req: s.UpdateAlbumRequestSchema, resp: s.AlbumSchema, auth: { type: 'role', role: 'admin' } as const },
202
224
  update_artist: { method: 'POST', path: '/api/artists/update', req: s.UpdateArtistRequestSchema, resp: s.ArtistSchema, auth: { type: 'role', role: 'admin' } as const },
203
225
  update_artist_metadata: { method: 'POST', path: '/api/artists/update-metadata', req: s.UpdateArtistMetadataRequestSchema, resp: s.UpdateArtistMetadataResponseSchema, auth: { type: 'role', role: 'admin' } as const },
204
- update_listen_session_progress: { method: 'POST', path: '/api/analytics/sessions/progress', req: s.UpdateListenSessionProgressRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'owner' } as const },
205
- update_listen_session_songs: { method: 'POST', path: '/api/analytics/sessions/songs', req: s.UpdateListenSessionSongsRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'owner' } as const },
206
- update_listen_session_status: { method: 'POST', path: '/api/analytics/sessions/status', req: s.UpdateListenSessionStatusRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'owner' } as const },
226
+ update_playback_session_items: { method: 'POST', path: '/api/analytics/sessions/items', req: s.UpdatePlaybackSessionItemsRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'owner' } as const },
227
+ update_playback_session_progress: { method: 'POST', path: '/api/analytics/sessions/progress', req: s.UpdatePlaybackSessionProgressRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'owner' } as const },
228
+ update_playback_session_status: { method: 'POST', path: '/api/analytics/sessions/status', req: s.UpdatePlaybackSessionStatusRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'owner' } as const },
207
229
  update_playlist: { method: 'POST', path: '/api/playlists/update', req: s.UpdatePlaylistRequestSchema, resp: s.PlaylistSchema, auth: { type: 'owner_or', role: 'admin' } as const },
208
230
  update_songs: { method: 'POST', path: '/api/songs/update', req: s.UpdateSongsRequestSchema, resp: s.UpdateSongsResultSchema, auth: { type: 'role', role: 'admin' } as const },
209
231
  upload_image: { method: 'POST', path: '/api/upload/image', req: null, resp: s.ImageUploadResponseSchema, auth: { type: 'role', role: 'member' } as const },
210
232
  upload_music: { method: 'POST', path: '/api/upload/music', req: null, resp: s.MusicUploadResponseSchema, auth: { type: 'role', role: 'member' } as const },
211
233
  upload_music_by_blake3: { method: 'POST', path: '/api/upload/music-by-blake3', req: s.UploadMusicByBlake3RequestSchema, resp: s.MusicUploadResponseSchema, auth: { type: 'role', role: 'member' } as const },
212
234
  },
235
+ video: {
236
+ bulk_delete_videos: { method: 'POST', path: '/api/video/videos/bulk-delete', req: s.BulkDeleteVideosRequestSchema, resp: s.BulkDeleteVideosResponseSchema, auth: { type: 'role', role: 'admin' } as const },
237
+ create_video: { method: 'POST', path: '/api/video/videos', req: s.CreateVideoRequestSchema, resp: s.VideoSchema, auth: { type: 'role', role: 'admin' } as const },
238
+ create_video_season: { method: 'POST', path: '/api/video/seasons', req: s.CreateVideoSeasonRequestSchema, resp: s.VideoSeasonSchema, auth: { type: 'role', role: 'admin' } as const },
239
+ create_video_series: { method: 'POST', path: '/api/video/series', req: s.CreateVideoSeriesRequestSchema, resp: s.VideoSeriesSchema, auth: { type: 'role', role: 'admin' } as const },
240
+ delete_video: { method: 'POST', path: '/api/video/videos/delete', req: s.DeleteVideoRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'role', role: 'admin' } as const },
241
+ delete_video_rendition: { method: 'POST', path: '/api/video/videos/renditions/delete', req: s.DeleteVideoRenditionRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'role', role: 'admin' } as const },
242
+ delete_video_season: { method: 'POST', path: '/api/video/seasons/delete', req: s.DeleteVideoSeasonRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'role', role: 'admin' } as const },
243
+ delete_video_series: { method: 'POST', path: '/api/video/series/delete', req: s.DeleteVideoSeriesRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'role', role: 'admin' } as const },
244
+ get_playback_progress: { method: 'POST', path: '/api/video/progress/get', req: s.GetPlaybackProgressRequestSchema, resp: s.PlaybackProgressSchema.array(), auth: { type: 'authenticated' } as const },
245
+ get_video: { method: 'POST', path: '/api/video/videos/get', req: s.GetVideoRequestSchema, resp: s.VideoSchema, auth: { type: 'authenticated' } as const },
246
+ get_video_renditions: { method: 'POST', path: '/api/video/videos/renditions', req: s.GetVideoRenditionsRequestSchema, resp: s.VideoRenditionSchema.array(), auth: { type: 'authenticated' } as const },
247
+ get_video_season: { method: 'POST', path: '/api/video/seasons/get', req: s.GetVideoSeasonRequestSchema, resp: s.VideoSeasonSchema, auth: { type: 'authenticated' } as const },
248
+ get_video_series: { method: 'POST', path: '/api/video/series/get', req: s.GetVideoSeriesRequestSchema, resp: s.VideoSeriesSchema, auth: { type: 'authenticated' } as const },
249
+ get_video_series_detail: { method: 'POST', path: '/api/video/series/detail', req: s.GetVideoSeriesRequestSchema, resp: s.SeriesDetailSchema, auth: { type: 'authenticated' } as const },
250
+ get_video_with_metadata: { method: 'POST', path: '/api/video/videos/get-with-metadata', req: s.GetVideoRequestSchema, resp: s.VideoWithMetadataSchema, auth: { type: 'authenticated' } as const },
251
+ list_pending_video_import_review: { method: 'POST', path: '/api/video/import/pending', req: s.ListPendingVideoReviewRequestSchema, resp: s.PendingVideoReviewSessionSchema.array(), auth: { type: 'authenticated' } as const },
252
+ list_playback_progress: { method: 'POST', path: '/api/video/progress/list', req: s.ListPlaybackProgressRequestSchema, resp: s.PlaybackProgressSchema.array(), auth: { type: 'authenticated' } as const },
253
+ list_video_seasons: { method: 'POST', path: '/api/video/seasons/list', req: s.ListVideoSeasonsRequestSchema, resp: s.VideoSeasonSchema.array(), auth: { type: 'authenticated' } as const },
254
+ list_video_series: { method: 'POST', path: '/api/video/series/list', req: s.ListVideoSeriesRequestSchema, resp: s.VideoSeriesSchema.array(), auth: { type: 'authenticated' } as const },
255
+ list_videos_by_season: { method: 'POST', path: '/api/video/videos/list-by-season', req: s.ListVideosBySeasonRequestSchema, resp: s.VideoSchema.array(), auth: { type: 'authenticated' } as const },
256
+ list_videos_by_series: { method: 'POST', path: '/api/video/videos/list-by-series', req: s.ListVideosBySeriesRequestSchema, resp: s.VideoSchema.array(), auth: { type: 'authenticated' } as const },
257
+ list_videos_unattached: { method: 'POST', path: '/api/video/videos/list-unattached', req: s.ListVideosUnattachedRequestSchema, resp: s.VideoSchema.array(), auth: { type: 'authenticated' } as const },
258
+ mark_video_group_reviewed: { method: 'POST', path: '/api/video/import/mark-reviewed', req: s.MarkVideoGroupReviewedRequestSchema, resp: s.VideoImportReviewOkSchema, auth: { type: 'owner_or', role: 'admin' } as const },
259
+ move_video_review: { method: 'POST', path: '/api/video/import/move-video', req: s.MoveVideoReviewRequestSchema, resp: s.VideoImportReviewOkSchema, auth: { type: 'owner_or', role: 'admin' } as const },
260
+ patch_video_group_review: { method: 'POST', path: '/api/video/import/patch-group', req: s.PatchVideoGroupReviewRequestSchema, resp: s.VideoImportReviewOkSchema, auth: { type: 'owner_or', role: 'admin' } as const },
261
+ query_video_series: { method: 'POST', path: '/api/video/series/query', req: s.QueryParamsSchema, resp: s.SeriesQueryResultSchema, auth: { type: 'authenticated' } as const },
262
+ query_videos: { method: 'POST', path: '/api/video/videos/query', req: s.QueryVideosRequestSchema, resp: s.VideosQueryResultSchema, auth: { type: 'authenticated' } as const },
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
+ 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
+ unassigned_videos: { method: 'POST', path: '/api/video/relations/unassigned-videos', req: s.UnassignedVideosRequestSchema, resp: s.UnassignedVideosResponseSchema, auth: { type: 'authenticated' } as const },
266
+ update_video_season: { method: 'POST', path: '/api/video/seasons/update', req: s.UpdateVideoSeasonRequestSchema, resp: s.VideoSeasonSchema, auth: { type: 'role', role: 'admin' } as const },
267
+ update_video_series: { method: 'POST', path: '/api/video/series/update', req: s.UpdateVideoSeriesRequestSchema, resp: s.VideoSeriesSchema, auth: { type: 'role', role: 'admin' } as const },
268
+ update_videos: { method: 'POST', path: '/api/video/videos/update', req: s.UpdateVideosRequestSchema, resp: s.UpdateVideosResultSchema, auth: { type: 'role', role: 'admin' } as const },
269
+ upload_video: { method: 'POST', path: '/api/upload/video', req: null, resp: s.VideoUploadResponseSchema, auth: { type: 'role', role: 'member' } as const },
270
+ upload_video_by_blake3: { method: 'POST', path: '/api/upload/video-by-blake3', req: s.UploadVideoByBlake3RequestSchema, resp: s.VideoUploadResponseSchema, auth: { type: 'role', role: 'member' } as const },
271
+ upsert_playback_progress: { method: 'POST', path: '/api/video/progress/upsert', req: s.UpsertPlaybackProgressRequestSchema, resp: s.PlaybackProgressSchema, auth: { type: 'authenticated' } as const },
272
+ video_pending: { method: 'POST', path: '/api/video/import/video-pending', req: s.VideoPendingRequestSchema, resp: s.VideoPendingResponseSchema, auth: { type: 'authenticated' } as const },
273
+ videos_by_value: { method: 'POST', path: '/api/video/relations/videos-by-value', req: s.VideosByValueRequestSchema, resp: s.VideosByValueResponseSchema, auth: { type: 'authenticated' } as const },
274
+ },
213
275
  } as const;