@churchapps/content-provider-helper 0.0.1 → 0.0.3

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/dist/index.cjs CHANGED
@@ -21,20 +21,44 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  APlayProvider: () => APlayProvider,
24
+ ApiHelper: () => ApiHelper,
24
25
  B1ChurchProvider: () => B1ChurchProvider,
25
26
  BibleProjectProvider: () => BibleProjectProvider,
26
27
  ContentProvider: () => ContentProvider,
28
+ DeviceFlowHelper: () => DeviceFlowHelper,
29
+ FormatConverters: () => FormatConverters_exports,
30
+ FormatResolver: () => FormatResolver,
31
+ HighVoltageKidsProvider: () => HighVoltageKidsProvider,
27
32
  LessonsChurchProvider: () => LessonsChurchProvider,
33
+ OAuthHelper: () => OAuthHelper,
28
34
  PlanningCenterProvider: () => PlanningCenterProvider,
29
35
  SignPresenterProvider: () => SignPresenterProvider,
36
+ TokenHelper: () => TokenHelper,
30
37
  VERSION: () => VERSION,
38
+ appendToPath: () => appendToPath,
39
+ buildPath: () => buildPath,
40
+ collapseInstructions: () => collapseInstructions,
41
+ createFile: () => createFile,
42
+ createFolder: () => createFolder,
31
43
  detectMediaType: () => detectMediaType,
44
+ expandedInstructionsToPlaylist: () => expandedInstructionsToPlaylist,
45
+ expandedInstructionsToPresentations: () => expandedInstructionsToPresentations,
32
46
  getAllProviders: () => getAllProviders,
33
47
  getAvailableProviders: () => getAvailableProviders,
34
48
  getProvider: () => getProvider,
35
49
  getProviderConfig: () => getProviderConfig,
50
+ getSegment: () => getSegment,
51
+ instructionsToPlaylist: () => instructionsToPlaylist,
52
+ instructionsToPresentations: () => instructionsToPresentations,
36
53
  isContentFile: () => isContentFile,
37
54
  isContentFolder: () => isContentFolder,
55
+ parsePath: () => parsePath,
56
+ playlistToExpandedInstructions: () => playlistToExpandedInstructions,
57
+ playlistToInstructions: () => playlistToInstructions,
58
+ playlistToPresentations: () => playlistToPresentations,
59
+ presentationsToExpandedInstructions: () => presentationsToExpandedInstructions,
60
+ presentationsToInstructions: () => presentationsToInstructions,
61
+ presentationsToPlaylist: () => presentationsToPlaylist,
38
62
  registerProvider: () => registerProvider
39
63
  });
40
64
  module.exports = __toCommonJS(index_exports);
@@ -53,105 +77,379 @@ function detectMediaType(url, explicitType) {
53
77
  const videoPatterns = [".mp4", ".webm", ".m3u8", ".mov", "stream.mux.com"];
54
78
  return videoPatterns.some((p) => url.includes(p)) ? "video" : "image";
55
79
  }
80
+ function createFolder(id, title, path, image, providerData, isLeaf) {
81
+ return { type: "folder", id, title, path, image, isLeaf, providerData };
82
+ }
83
+ function createFile(id, title, url, options) {
84
+ return { type: "file", id, title, url, mediaType: options?.mediaType ?? detectMediaType(url), image: options?.image, muxPlaybackId: options?.muxPlaybackId, providerData: options?.providerData };
85
+ }
56
86
 
57
- // src/ContentProvider.ts
58
- var ContentProvider = class {
59
- /**
60
- * Get a flat list of media files (playlist) for a folder.
61
- * Override in subclass if the provider supports playlists.
62
- * @param _folder - The folder to get playlist for (typically a venue or playlist folder)
63
- * @param _auth - Optional authentication data
64
- * @param _resolution - Optional resolution hint for video quality
65
- * @returns Array of ContentFile objects, or null if not supported
66
- */
67
- getPlaylist(_folder, _auth, _resolution) {
68
- return Promise.resolve(null);
87
+ // src/pathUtils.ts
88
+ function parsePath(path) {
89
+ if (!path || path === "/" || path === "") {
90
+ return { segments: [], depth: 0 };
69
91
  }
70
- /**
71
- * Get instruction/run sheet data for a folder.
72
- * Override in subclass if the provider supports instructions.
73
- * @param _folder - The folder to get instructions for
74
- * @param _auth - Optional authentication data
75
- * @returns Instructions object, or null if not supported
76
- */
77
- getInstructions(_folder, _auth) {
78
- return Promise.resolve(null);
92
+ const segments = path.replace(/^\//, "").split("/").filter(Boolean);
93
+ return { segments, depth: segments.length };
94
+ }
95
+ function getSegment(path, index) {
96
+ const { segments } = parsePath(path);
97
+ return segments[index] ?? null;
98
+ }
99
+ function buildPath(segments) {
100
+ if (segments.length === 0) return "";
101
+ return "/" + segments.join("/");
102
+ }
103
+ function appendToPath(basePath, segment) {
104
+ if (!basePath || basePath === "/" || basePath === "") {
105
+ return "/" + segment;
79
106
  }
80
- /**
81
- * Get expanded instruction data with actions for a folder.
82
- * Override in subclass if the provider supports expanded instructions.
83
- * @param _folder - The folder to get expanded instructions for
84
- * @param _auth - Optional authentication data
85
- * @returns Instructions object with expanded action data, or null if not supported
86
- */
87
- getExpandedInstructions(_folder, _auth) {
88
- return Promise.resolve(null);
107
+ const cleanBase = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
108
+ return cleanBase + "/" + segment;
109
+ }
110
+
111
+ // src/FormatConverters.ts
112
+ var FormatConverters_exports = {};
113
+ __export(FormatConverters_exports, {
114
+ collapseInstructions: () => collapseInstructions,
115
+ expandedInstructionsToPlaylist: () => expandedInstructionsToPlaylist,
116
+ expandedInstructionsToPresentations: () => expandedInstructionsToPresentations,
117
+ instructionsToPlaylist: () => instructionsToPlaylist,
118
+ instructionsToPresentations: () => instructionsToPresentations,
119
+ playlistToExpandedInstructions: () => playlistToExpandedInstructions,
120
+ playlistToInstructions: () => playlistToInstructions,
121
+ playlistToPresentations: () => playlistToPresentations,
122
+ presentationsToExpandedInstructions: () => presentationsToExpandedInstructions,
123
+ presentationsToInstructions: () => presentationsToInstructions,
124
+ presentationsToPlaylist: () => presentationsToPlaylist
125
+ });
126
+ function generateId() {
127
+ return "gen-" + Math.random().toString(36).substring(2, 11);
128
+ }
129
+ function mapItemTypeToActionType(itemType) {
130
+ switch (itemType) {
131
+ case "action":
132
+ case "lessonAction":
133
+ case "providerPresentation":
134
+ case "play":
135
+ return "play";
136
+ case "addon":
137
+ case "add-on":
138
+ case "lessonAddOn":
139
+ case "providerFile":
140
+ return "add-on";
141
+ default:
142
+ return "other";
89
143
  }
90
- /**
91
- * Check if this provider requires authentication.
92
- * @returns true if authentication is required
93
- */
94
- requiresAuth() {
95
- return !!this.config.clientId;
144
+ }
145
+ function mapActionTypeToItemType(actionType) {
146
+ switch (actionType) {
147
+ case "play":
148
+ return "action";
149
+ case "add-on":
150
+ return "addon";
151
+ default:
152
+ return "item";
96
153
  }
97
- /**
98
- * Get the capabilities supported by this provider.
99
- * Override in subclass to indicate supported features.
100
- * @returns ProviderCapabilities object
101
- */
102
- getCapabilities() {
154
+ }
155
+ function presentationsToPlaylist(plan) {
156
+ if (plan.allFiles && plan.allFiles.length > 0) {
157
+ return [...plan.allFiles];
158
+ }
159
+ const files = [];
160
+ for (const section of plan.sections) {
161
+ for (const presentation of section.presentations) {
162
+ files.push(...presentation.files);
163
+ }
164
+ }
165
+ return files;
166
+ }
167
+ function presentationsToInstructions(plan) {
168
+ return { venueName: plan.name, items: plan.sections.map((section) => ({ id: section.id, itemType: "section", label: section.name, children: section.presentations.map((pres) => {
169
+ const totalSeconds = pres.files.reduce((sum, f) => sum + (f.providerData?.seconds || 0), 0);
170
+ return { id: pres.id, itemType: mapActionTypeToItemType(pres.actionType), label: pres.name, seconds: totalSeconds || void 0, embedUrl: pres.files[0]?.embedUrl || pres.files[0]?.url };
171
+ }) })) };
172
+ }
173
+ function presentationsToExpandedInstructions(plan) {
174
+ return { venueName: plan.name, items: plan.sections.map((section) => ({ id: section.id, itemType: "section", label: section.name, children: section.presentations.map((pres) => ({ id: pres.id, itemType: mapActionTypeToItemType(pres.actionType), label: pres.name, description: pres.actionType !== "other" ? pres.actionType : void 0, seconds: pres.files.reduce((sum, f) => sum + (f.providerData?.seconds || 0), 0) || void 0, children: pres.files.map((f) => ({ id: f.id, itemType: "file", label: f.title, seconds: f.providerData?.seconds || void 0, embedUrl: f.embedUrl || f.url })) })) })) };
175
+ }
176
+ function instructionsToPlaylist(instructions) {
177
+ const files = [];
178
+ function extractFiles(items) {
179
+ for (const item of items) {
180
+ if (item.embedUrl && (item.itemType === "file" || !item.children?.length)) {
181
+ files.push({ type: "file", id: item.id || item.relatedId || generateId(), title: item.label || "Untitled", mediaType: detectMediaType(item.embedUrl), url: item.embedUrl, embedUrl: item.embedUrl, providerData: item.seconds ? { seconds: item.seconds } : void 0 });
182
+ }
183
+ if (item.children) {
184
+ extractFiles(item.children);
185
+ }
186
+ }
187
+ }
188
+ extractFiles(instructions.items);
189
+ return files;
190
+ }
191
+ var expandedInstructionsToPlaylist = instructionsToPlaylist;
192
+ function instructionsToPresentations(instructions, planId) {
193
+ const allFiles = [];
194
+ const sections = instructions.items.filter((item) => item.children && item.children.length > 0).map((sectionItem) => {
195
+ const presentations = (sectionItem.children || []).map((presItem) => {
196
+ const files = [];
197
+ if (presItem.children && presItem.children.length > 0) {
198
+ for (const child of presItem.children) {
199
+ if (child.embedUrl) {
200
+ const file = { type: "file", id: child.id || child.relatedId || generateId(), title: child.label || "Untitled", mediaType: detectMediaType(child.embedUrl), url: child.embedUrl, embedUrl: child.embedUrl, providerData: child.seconds ? { seconds: child.seconds } : void 0 };
201
+ allFiles.push(file);
202
+ files.push(file);
203
+ }
204
+ }
205
+ }
206
+ if (files.length === 0 && presItem.embedUrl) {
207
+ const file = { type: "file", id: presItem.id || presItem.relatedId || generateId(), title: presItem.label || "Untitled", mediaType: detectMediaType(presItem.embedUrl), url: presItem.embedUrl, embedUrl: presItem.embedUrl, providerData: presItem.seconds ? { seconds: presItem.seconds } : void 0 };
208
+ allFiles.push(file);
209
+ files.push(file);
210
+ }
211
+ return { id: presItem.id || presItem.relatedId || generateId(), name: presItem.label || "Presentation", actionType: mapItemTypeToActionType(presItem.itemType), files };
212
+ });
213
+ return { id: sectionItem.id || sectionItem.relatedId || generateId(), name: sectionItem.label || "Section", presentations };
214
+ });
215
+ return { id: planId || generateId(), name: instructions.venueName || "Plan", sections, allFiles };
216
+ }
217
+ var expandedInstructionsToPresentations = instructionsToPresentations;
218
+ function collapseInstructions(instructions, maxDepth = 2) {
219
+ function collapseItem(item, currentDepth) {
220
+ if (currentDepth >= maxDepth || !item.children || item.children.length === 0) {
221
+ const { children, ...rest } = item;
222
+ if (children && children.length > 0) {
223
+ const totalSeconds = children.reduce((sum, c) => sum + (c.seconds || 0), 0);
224
+ if (totalSeconds > 0) {
225
+ rest.seconds = totalSeconds;
226
+ }
227
+ if (!rest.embedUrl) {
228
+ const firstWithUrl = children.find((c) => c.embedUrl);
229
+ if (firstWithUrl) {
230
+ rest.embedUrl = firstWithUrl.embedUrl;
231
+ }
232
+ }
233
+ }
234
+ return rest;
235
+ }
103
236
  return {
104
- browse: true,
105
- presentations: false,
106
- playlist: false,
107
- instructions: false,
108
- expandedInstructions: false,
109
- mediaLicensing: false
237
+ ...item,
238
+ children: item.children.map((child) => collapseItem(child, currentDepth + 1))
110
239
  };
111
240
  }
112
- /**
113
- * Check the license status for a specific media item.
114
- * Override in subclass if the provider requires license validation.
115
- * @param _mediaId - The media ID to check
116
- * @param _auth - Optional authentication data
117
- * @returns MediaLicenseResult object, or null if not supported
118
- */
119
- checkMediaLicense(_mediaId, _auth) {
120
- return Promise.resolve(null);
241
+ return {
242
+ venueName: instructions.venueName,
243
+ items: instructions.items.map((item) => collapseItem(item, 0))
244
+ };
245
+ }
246
+ function playlistToPresentations(files, planName = "Playlist", sectionName = "Content") {
247
+ const presentations = files.map((file, index) => ({ id: `pres-${index}-${file.id}`, name: file.title, actionType: "play", files: [file] }));
248
+ return { id: "playlist-plan-" + generateId(), name: planName, sections: [{ id: "main-section", name: sectionName, presentations }], allFiles: [...files] };
249
+ }
250
+ function playlistToInstructions(files, venueName = "Playlist") {
251
+ return { venueName, items: [{ id: "main-section", itemType: "section", label: "Content", children: files.map((file, index) => ({ id: file.id || `item-${index}`, itemType: "file", label: file.title, seconds: file.providerData?.seconds || void 0, embedUrl: file.embedUrl || file.url })) }] };
252
+ }
253
+ var playlistToExpandedInstructions = playlistToInstructions;
254
+
255
+ // src/FormatResolver.ts
256
+ var FormatResolver = class {
257
+ constructor(provider, options = {}) {
258
+ this.provider = provider;
259
+ this.options = { allowLossy: options.allowLossy ?? true };
121
260
  }
122
- /**
123
- * Get the authentication types supported by this provider.
124
- * @returns Array of supported AuthType values ('none', 'oauth_pkce', 'device_flow')
125
- */
126
- getAuthTypes() {
127
- if (!this.requiresAuth()) return ["none"];
128
- const types = ["oauth_pkce"];
129
- if (this.supportsDeviceFlow()) types.push("device_flow");
130
- return types;
261
+ getProvider() {
262
+ return this.provider;
131
263
  }
132
- /**
133
- * Check if the provided auth data is still valid (not expired).
134
- * @param auth - Authentication data to validate
135
- * @returns true if auth is valid and not expired
136
- */
137
- isAuthValid(auth) {
138
- if (!auth) return false;
139
- return !this.isTokenExpired(auth);
264
+ /** Extract the last segment from a path to use as fallback ID/title */
265
+ getIdFromPath(path) {
266
+ const { segments } = parsePath(path);
267
+ return segments[segments.length - 1] || "content";
140
268
  }
141
- /**
142
- * Check if a token is expired (with 5-minute buffer).
143
- * @param auth - Authentication data to check
144
- * @returns true if token is expired or will expire within 5 minutes
145
- */
146
- isTokenExpired(auth) {
147
- if (!auth.created_at || !auth.expires_in) return true;
148
- const expiresAt = (auth.created_at + auth.expires_in) * 1e3;
149
- return Date.now() > expiresAt - 5 * 60 * 1e3;
269
+ async getPlaylist(path, auth) {
270
+ const caps = this.provider.capabilities;
271
+ if (caps.playlist && this.provider.getPlaylist) {
272
+ const result = await this.provider.getPlaylist(path, auth);
273
+ if (result && result.length > 0) return result;
274
+ }
275
+ if (caps.presentations) {
276
+ const plan = await this.provider.getPresentations(path, auth);
277
+ if (plan) return presentationsToPlaylist(plan);
278
+ }
279
+ if (caps.expandedInstructions && this.provider.getExpandedInstructions) {
280
+ const expanded = await this.provider.getExpandedInstructions(path, auth);
281
+ if (expanded) return instructionsToPlaylist(expanded);
282
+ }
283
+ if (this.options.allowLossy && caps.instructions && this.provider.getInstructions) {
284
+ const instructions = await this.provider.getInstructions(path, auth);
285
+ if (instructions) return instructionsToPlaylist(instructions);
286
+ }
287
+ return null;
150
288
  }
151
- /**
152
- * Generate a random code verifier for PKCE.
153
- * @returns A 64-character random string
154
- */
289
+ async getPlaylistWithMeta(path, auth) {
290
+ const caps = this.provider.capabilities;
291
+ if (caps.playlist && this.provider.getPlaylist) {
292
+ const result = await this.provider.getPlaylist(path, auth);
293
+ if (result && result.length > 0) {
294
+ return { data: result, meta: { isNative: true, isLossy: false } };
295
+ }
296
+ }
297
+ if (caps.presentations) {
298
+ const plan = await this.provider.getPresentations(path, auth);
299
+ if (plan) return { data: presentationsToPlaylist(plan), meta: { isNative: false, sourceFormat: "presentations", isLossy: false } };
300
+ }
301
+ if (caps.expandedInstructions && this.provider.getExpandedInstructions) {
302
+ const expanded = await this.provider.getExpandedInstructions(path, auth);
303
+ if (expanded) return { data: instructionsToPlaylist(expanded), meta: { isNative: false, sourceFormat: "expandedInstructions", isLossy: false } };
304
+ }
305
+ if (this.options.allowLossy && caps.instructions && this.provider.getInstructions) {
306
+ const instructions = await this.provider.getInstructions(path, auth);
307
+ if (instructions) return { data: instructionsToPlaylist(instructions), meta: { isNative: false, sourceFormat: "instructions", isLossy: true } };
308
+ }
309
+ return { data: null, meta: { isNative: false, isLossy: false } };
310
+ }
311
+ async getPresentations(path, auth) {
312
+ const caps = this.provider.capabilities;
313
+ const fallbackId = this.getIdFromPath(path);
314
+ if (caps.presentations) {
315
+ const result = await this.provider.getPresentations(path, auth);
316
+ if (result) return result;
317
+ }
318
+ if (caps.expandedInstructions && this.provider.getExpandedInstructions) {
319
+ const expanded = await this.provider.getExpandedInstructions(path, auth);
320
+ if (expanded) return instructionsToPresentations(expanded, fallbackId);
321
+ }
322
+ if (caps.instructions && this.provider.getInstructions) {
323
+ const instructions = await this.provider.getInstructions(path, auth);
324
+ if (instructions) return instructionsToPresentations(instructions, fallbackId);
325
+ }
326
+ if (this.options.allowLossy && caps.playlist && this.provider.getPlaylist) {
327
+ const playlist = await this.provider.getPlaylist(path, auth);
328
+ if (playlist && playlist.length > 0) {
329
+ return playlistToPresentations(playlist, fallbackId);
330
+ }
331
+ }
332
+ return null;
333
+ }
334
+ async getPresentationsWithMeta(path, auth) {
335
+ const caps = this.provider.capabilities;
336
+ const fallbackId = this.getIdFromPath(path);
337
+ if (caps.presentations) {
338
+ const result = await this.provider.getPresentations(path, auth);
339
+ if (result) {
340
+ return { data: result, meta: { isNative: true, isLossy: false } };
341
+ }
342
+ }
343
+ if (caps.expandedInstructions && this.provider.getExpandedInstructions) {
344
+ const expanded = await this.provider.getExpandedInstructions(path, auth);
345
+ if (expanded) return { data: instructionsToPresentations(expanded, fallbackId), meta: { isNative: false, sourceFormat: "expandedInstructions", isLossy: false } };
346
+ }
347
+ if (caps.instructions && this.provider.getInstructions) {
348
+ const instructions = await this.provider.getInstructions(path, auth);
349
+ if (instructions) return { data: instructionsToPresentations(instructions, fallbackId), meta: { isNative: false, sourceFormat: "instructions", isLossy: true } };
350
+ }
351
+ if (this.options.allowLossy && caps.playlist && this.provider.getPlaylist) {
352
+ const playlist = await this.provider.getPlaylist(path, auth);
353
+ if (playlist && playlist.length > 0) return { data: playlistToPresentations(playlist, fallbackId), meta: { isNative: false, sourceFormat: "playlist", isLossy: true } };
354
+ }
355
+ return { data: null, meta: { isNative: false, isLossy: false } };
356
+ }
357
+ async getInstructions(path, auth) {
358
+ const caps = this.provider.capabilities;
359
+ const fallbackTitle = this.getIdFromPath(path);
360
+ if (caps.instructions && this.provider.getInstructions) {
361
+ const result = await this.provider.getInstructions(path, auth);
362
+ if (result) return result;
363
+ }
364
+ if (caps.expandedInstructions && this.provider.getExpandedInstructions) {
365
+ const expanded = await this.provider.getExpandedInstructions(path, auth);
366
+ if (expanded) return collapseInstructions(expanded);
367
+ }
368
+ if (caps.presentations) {
369
+ const plan = await this.provider.getPresentations(path, auth);
370
+ if (plan) return presentationsToInstructions(plan);
371
+ }
372
+ if (this.options.allowLossy && caps.playlist && this.provider.getPlaylist) {
373
+ const playlist = await this.provider.getPlaylist(path, auth);
374
+ if (playlist && playlist.length > 0) {
375
+ return playlistToInstructions(playlist, fallbackTitle);
376
+ }
377
+ }
378
+ return null;
379
+ }
380
+ async getInstructionsWithMeta(path, auth) {
381
+ const caps = this.provider.capabilities;
382
+ const fallbackTitle = this.getIdFromPath(path);
383
+ if (caps.instructions && this.provider.getInstructions) {
384
+ const result = await this.provider.getInstructions(path, auth);
385
+ if (result) {
386
+ return { data: result, meta: { isNative: true, isLossy: false } };
387
+ }
388
+ }
389
+ if (caps.expandedInstructions && this.provider.getExpandedInstructions) {
390
+ const expanded = await this.provider.getExpandedInstructions(path, auth);
391
+ if (expanded) return { data: collapseInstructions(expanded), meta: { isNative: false, sourceFormat: "expandedInstructions", isLossy: true } };
392
+ }
393
+ if (caps.presentations) {
394
+ const plan = await this.provider.getPresentations(path, auth);
395
+ if (plan) return { data: presentationsToInstructions(plan), meta: { isNative: false, sourceFormat: "presentations", isLossy: false } };
396
+ }
397
+ if (this.options.allowLossy && caps.playlist && this.provider.getPlaylist) {
398
+ const playlist = await this.provider.getPlaylist(path, auth);
399
+ if (playlist && playlist.length > 0) return { data: playlistToInstructions(playlist, fallbackTitle), meta: { isNative: false, sourceFormat: "playlist", isLossy: true } };
400
+ }
401
+ return { data: null, meta: { isNative: false, isLossy: false } };
402
+ }
403
+ async getExpandedInstructions(path, auth) {
404
+ const caps = this.provider.capabilities;
405
+ const fallbackTitle = this.getIdFromPath(path);
406
+ if (caps.expandedInstructions && this.provider.getExpandedInstructions) {
407
+ const result = await this.provider.getExpandedInstructions(path, auth);
408
+ if (result) return result;
409
+ }
410
+ if (caps.presentations) {
411
+ const plan = await this.provider.getPresentations(path, auth);
412
+ if (plan) return presentationsToExpandedInstructions(plan);
413
+ }
414
+ if (caps.instructions && this.provider.getInstructions) {
415
+ const instructions = await this.provider.getInstructions(path, auth);
416
+ if (instructions) return instructions;
417
+ }
418
+ if (this.options.allowLossy && caps.playlist && this.provider.getPlaylist) {
419
+ const playlist = await this.provider.getPlaylist(path, auth);
420
+ if (playlist && playlist.length > 0) {
421
+ return playlistToInstructions(playlist, fallbackTitle);
422
+ }
423
+ }
424
+ return null;
425
+ }
426
+ async getExpandedInstructionsWithMeta(path, auth) {
427
+ const caps = this.provider.capabilities;
428
+ const fallbackTitle = this.getIdFromPath(path);
429
+ if (caps.expandedInstructions && this.provider.getExpandedInstructions) {
430
+ const result = await this.provider.getExpandedInstructions(path, auth);
431
+ if (result) {
432
+ return { data: result, meta: { isNative: true, isLossy: false } };
433
+ }
434
+ }
435
+ if (caps.presentations) {
436
+ const plan = await this.provider.getPresentations(path, auth);
437
+ if (plan) return { data: presentationsToExpandedInstructions(plan), meta: { isNative: false, sourceFormat: "presentations", isLossy: false } };
438
+ }
439
+ if (caps.instructions && this.provider.getInstructions) {
440
+ const instructions = await this.provider.getInstructions(path, auth);
441
+ if (instructions) return { data: instructions, meta: { isNative: false, sourceFormat: "instructions", isLossy: true } };
442
+ }
443
+ if (this.options.allowLossy && caps.playlist && this.provider.getPlaylist) {
444
+ const playlist = await this.provider.getPlaylist(path, auth);
445
+ if (playlist && playlist.length > 0) return { data: playlistToInstructions(playlist, fallbackTitle), meta: { isNative: false, sourceFormat: "playlist", isLossy: true } };
446
+ }
447
+ return { data: null, meta: { isNative: false, isLossy: false } };
448
+ }
449
+ };
450
+
451
+ // src/helpers/OAuthHelper.ts
452
+ var OAuthHelper = class {
155
453
  generateCodeVerifier() {
156
454
  const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
157
455
  const length = 64;
@@ -163,11 +461,6 @@ var ContentProvider = class {
163
461
  }
164
462
  return result;
165
463
  }
166
- /**
167
- * Generate a code challenge from a code verifier using SHA-256.
168
- * @param verifier - The code verifier string
169
- * @returns Base64url-encoded SHA-256 hash of the verifier
170
- */
171
464
  async generateCodeChallenge(verifier) {
172
465
  const encoder = new TextEncoder();
173
466
  const data = encoder.encode(verifier);
@@ -180,142 +473,100 @@ var ContentProvider = class {
180
473
  const base64 = btoa(binary);
181
474
  return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
182
475
  }
183
- /**
184
- * Build the OAuth authorization URL for PKCE flow.
185
- * @param codeVerifier - The code verifier (store this for token exchange)
186
- * @param redirectUri - The redirect URI to return to after authorization
187
- * @param state - Optional state parameter for CSRF protection (defaults to provider ID)
188
- * @returns Object with authorization URL and challenge method
189
- */
190
- async buildAuthUrl(codeVerifier, redirectUri, state) {
476
+ async buildAuthUrl(config, codeVerifier, redirectUri, state) {
191
477
  const codeChallenge = await this.generateCodeChallenge(codeVerifier);
192
478
  const params = new URLSearchParams({
193
479
  response_type: "code",
194
- client_id: this.config.clientId,
480
+ client_id: config.clientId,
195
481
  redirect_uri: redirectUri,
196
- scope: this.config.scopes.join(" "),
482
+ scope: config.scopes.join(" "),
197
483
  code_challenge: codeChallenge,
198
484
  code_challenge_method: "S256",
199
- state: state || this.id
485
+ state: state || ""
200
486
  });
201
- return { url: `${this.config.oauthBase}/authorize?${params.toString()}`, challengeMethod: "S256" };
202
- }
203
- /**
204
- * Exchange an authorization code for access and refresh tokens.
205
- * @param code - The authorization code from the callback
206
- * @param codeVerifier - The original code verifier used to generate the challenge
207
- * @param redirectUri - The redirect URI (must match the one used in buildAuthUrl)
208
- * @returns Authentication data, or null if exchange failed
209
- */
210
- async exchangeCodeForTokens(code, codeVerifier, redirectUri) {
487
+ return { url: `${config.oauthBase}/authorize?${params.toString()}`, challengeMethod: "S256" };
488
+ }
489
+ async exchangeCodeForTokens(config, providerId, code, codeVerifier, redirectUri) {
211
490
  try {
212
491
  const params = new URLSearchParams({
213
492
  grant_type: "authorization_code",
214
493
  code,
215
494
  redirect_uri: redirectUri,
216
- client_id: this.config.clientId,
495
+ client_id: config.clientId,
217
496
  code_verifier: codeVerifier
218
497
  });
219
- const tokenUrl = `${this.config.oauthBase}/token`;
220
- console.log(`${this.id} token exchange request to: ${tokenUrl}`);
221
- console.log(` - client_id: ${this.config.clientId}`);
498
+ const tokenUrl = `${config.oauthBase}/token`;
499
+ console.log(`${providerId} token exchange request to: ${tokenUrl}`);
500
+ console.log(` - client_id: ${config.clientId}`);
222
501
  console.log(` - redirect_uri: ${redirectUri}`);
223
502
  console.log(` - code: ${code.substring(0, 10)}...`);
224
503
  const response = await fetch(tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params.toString() });
225
- console.log(`${this.id} token response status: ${response.status}`);
504
+ console.log(`${providerId} token response status: ${response.status}`);
226
505
  if (!response.ok) {
227
506
  const errorText = await response.text();
228
- console.error(`${this.id} token exchange failed: ${response.status} - ${errorText}`);
507
+ console.error(`${providerId} token exchange failed: ${response.status} - ${errorText}`);
229
508
  return null;
230
509
  }
231
510
  const data = await response.json();
232
- console.log(`${this.id} token exchange successful, got access_token: ${!!data.access_token}`);
233
- return {
234
- access_token: data.access_token,
235
- refresh_token: data.refresh_token,
236
- token_type: data.token_type || "Bearer",
237
- created_at: Math.floor(Date.now() / 1e3),
238
- expires_in: data.expires_in,
239
- scope: data.scope || this.config.scopes.join(" ")
240
- };
511
+ console.log(`${providerId} token exchange successful, got access_token: ${!!data.access_token}`);
512
+ return { access_token: data.access_token, refresh_token: data.refresh_token, token_type: data.token_type || "Bearer", created_at: Math.floor(Date.now() / 1e3), expires_in: data.expires_in, scope: data.scope || config.scopes.join(" ") };
241
513
  } catch (error) {
242
- console.error(`${this.id} token exchange error:`, error);
514
+ console.error(`${providerId} token exchange error:`, error);
243
515
  return null;
244
516
  }
245
517
  }
246
- /**
247
- * Refresh an expired access token using the refresh token.
248
- * @param auth - The current authentication data (must include refresh_token)
249
- * @returns New authentication data, or null if refresh failed
250
- */
251
- async refreshToken(auth) {
518
+ };
519
+
520
+ // src/helpers/TokenHelper.ts
521
+ var TokenHelper = class {
522
+ isAuthValid(auth) {
523
+ if (!auth) return false;
524
+ return !this.isTokenExpired(auth);
525
+ }
526
+ isTokenExpired(auth) {
527
+ if (!auth.created_at || !auth.expires_in) return true;
528
+ const expiresAt = (auth.created_at + auth.expires_in) * 1e3;
529
+ return Date.now() > expiresAt - 5 * 60 * 1e3;
530
+ }
531
+ async refreshToken(config, auth) {
252
532
  if (!auth.refresh_token) return null;
253
533
  try {
254
534
  const params = new URLSearchParams({
255
535
  grant_type: "refresh_token",
256
536
  refresh_token: auth.refresh_token,
257
- client_id: this.config.clientId
537
+ client_id: config.clientId
258
538
  });
259
- const response = await fetch(`${this.config.oauthBase}/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params.toString() });
539
+ const response = await fetch(`${config.oauthBase}/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params.toString() });
260
540
  if (!response.ok) return null;
261
541
  const data = await response.json();
262
- return {
263
- access_token: data.access_token,
264
- refresh_token: data.refresh_token || auth.refresh_token,
265
- token_type: data.token_type || "Bearer",
266
- created_at: Math.floor(Date.now() / 1e3),
267
- expires_in: data.expires_in,
268
- scope: data.scope || auth.scope
269
- };
542
+ return { access_token: data.access_token, refresh_token: data.refresh_token || auth.refresh_token, token_type: data.token_type || "Bearer", created_at: Math.floor(Date.now() / 1e3), expires_in: data.expires_in, scope: data.scope || auth.scope };
270
543
  } catch {
271
544
  return null;
272
545
  }
273
546
  }
274
- /**
275
- * Check if this provider supports device flow authentication.
276
- * @returns true if device flow is supported
277
- */
278
- supportsDeviceFlow() {
279
- return !!this.config.supportsDeviceFlow && !!this.config.deviceAuthEndpoint;
547
+ };
548
+
549
+ // src/helpers/DeviceFlowHelper.ts
550
+ var DeviceFlowHelper = class {
551
+ supportsDeviceFlow(config) {
552
+ return !!config.supportsDeviceFlow && !!config.deviceAuthEndpoint;
280
553
  }
281
- /**
282
- * Initiate the device authorization flow (RFC 8628).
283
- * @returns Device authorization response with user_code and verification_uri, or null if not supported
284
- */
285
- async initiateDeviceFlow() {
286
- if (!this.supportsDeviceFlow()) return null;
554
+ async initiateDeviceFlow(config) {
555
+ if (!this.supportsDeviceFlow(config)) return null;
287
556
  try {
288
- const params = new URLSearchParams({ client_id: this.config.clientId, scope: this.config.scopes.join(" ") });
289
- const response = await fetch(`${this.config.oauthBase}${this.config.deviceAuthEndpoint}`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params.toString() });
557
+ const response = await fetch(`${config.oauthBase}${config.deviceAuthEndpoint}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_id: config.clientId, scope: config.scopes.join(" ") }) });
290
558
  if (!response.ok) return null;
291
559
  return await response.json();
292
560
  } catch {
293
561
  return null;
294
562
  }
295
563
  }
296
- /**
297
- * Poll for a token after user has authorized the device.
298
- * @param deviceCode - The device_code from initiateDeviceFlow response
299
- * @returns Auth data if successful, error object if pending/slow_down, or null if failed/expired
300
- */
301
- async pollDeviceFlowToken(deviceCode) {
564
+ async pollDeviceFlowToken(config, deviceCode) {
302
565
  try {
303
- const params = new URLSearchParams({
304
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
305
- device_code: deviceCode,
306
- client_id: this.config.clientId
307
- });
308
- const response = await fetch(`${this.config.oauthBase}/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params.toString() });
566
+ const response = await fetch(`${config.oauthBase}/token`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: deviceCode, client_id: config.clientId }) });
309
567
  if (response.ok) {
310
568
  const data = await response.json();
311
- return {
312
- access_token: data.access_token,
313
- refresh_token: data.refresh_token,
314
- token_type: data.token_type || "Bearer",
315
- created_at: Math.floor(Date.now() / 1e3),
316
- expires_in: data.expires_in,
317
- scope: data.scope || this.config.scopes.join(" ")
318
- };
569
+ return { access_token: data.access_token, refresh_token: data.refresh_token, token_type: data.token_type || "Bearer", created_at: Math.floor(Date.now() / 1e3), expires_in: data.expires_in, scope: data.scope || config.scopes.join(" ") };
319
570
  }
320
571
  const errorData = await response.json();
321
572
  switch (errorData.error) {
@@ -334,221 +585,295 @@ var ContentProvider = class {
334
585
  return { error: "network_error" };
335
586
  }
336
587
  }
337
- /**
338
- * Calculate the delay between device flow poll attempts.
339
- * @param baseInterval - Base interval in seconds (default: 5)
340
- * @param slowDownCount - Number of slow_down responses received
341
- * @returns Delay in milliseconds
342
- */
343
588
  calculatePollDelay(baseInterval = 5, slowDownCount = 0) {
344
589
  return (baseInterval + slowDownCount * 5) * 1e3;
345
590
  }
346
- /**
347
- * Create authorization headers for API requests.
348
- * @param auth - Authentication data
349
- * @returns Headers object with Authorization header, or null if no auth
350
- */
591
+ };
592
+
593
+ // src/helpers/ApiHelper.ts
594
+ var ApiHelper = class {
351
595
  createAuthHeaders(auth) {
352
596
  if (!auth) return null;
353
597
  return { Authorization: `Bearer ${auth.access_token}`, Accept: "application/json" };
354
598
  }
355
- /**
356
- * Make an authenticated API request.
357
- * @param path - API endpoint path (appended to config.apiBase)
358
- * @param auth - Optional authentication data
359
- * @param method - HTTP method (default: 'GET')
360
- * @param body - Optional request body (for POST requests)
361
- * @returns Parsed JSON response, or null if request failed
362
- */
363
- async apiRequest(path, auth, method = "GET", body) {
599
+ async apiRequest(config, providerId, path, auth, method = "GET", body) {
364
600
  try {
365
- const url = `${this.config.apiBase}${path}`;
601
+ const url = `${config.apiBase}${path}`;
366
602
  const headers = { Accept: "application/json" };
367
603
  if (auth) headers["Authorization"] = `Bearer ${auth.access_token}`;
368
604
  if (body) headers["Content-Type"] = "application/json";
369
- console.log(`${this.id} API request: ${method} ${url}`);
370
- console.log(`${this.id} API auth present: ${!!auth}`);
605
+ console.log(`${providerId} API request: ${method} ${url}`);
606
+ console.log(`${providerId} API auth present: ${!!auth}`);
371
607
  const options = { method, headers, ...body ? { body: JSON.stringify(body) } : {} };
372
608
  const response = await fetch(url, options);
373
- console.log(`${this.id} API response status: ${response.status}`);
609
+ console.log(`${providerId} API response status: ${response.status}`);
374
610
  if (!response.ok) {
375
611
  const errorText = await response.text();
376
- console.error(`${this.id} API request failed: ${response.status} - ${errorText}`);
612
+ console.error(`${providerId} API request failed: ${response.status} - ${errorText}`);
377
613
  return null;
378
614
  }
379
615
  return await response.json();
380
616
  } catch (error) {
381
- console.error(`${this.id} API request error:`, error);
617
+ console.error(`${providerId} API request error:`, error);
382
618
  return null;
383
619
  }
384
620
  }
385
- /**
386
- * Helper to create a ContentFolder object.
387
- * @param id - Unique identifier
388
- * @param title - Display title
389
- * @param image - Optional image URL
390
- * @param providerData - Optional provider-specific data
391
- * @returns ContentFolder object
392
- */
393
- createFolder(id, title, image, providerData) {
394
- return { type: "folder", id, title, image, providerData };
395
- }
396
- /**
397
- * Helper to create a ContentFile object with automatic media type detection.
398
- * @param id - Unique identifier
399
- * @param title - Display title
400
- * @param url - Media URL
401
- * @param options - Optional properties (mediaType, image, muxPlaybackId, providerData)
402
- * @returns ContentFile object
403
- */
621
+ };
622
+
623
+ // src/ContentProvider.ts
624
+ var ContentProvider = class {
625
+ constructor() {
626
+ this.oauthHelper = new OAuthHelper();
627
+ this.tokenHelper = new TokenHelper();
628
+ this.deviceFlowHelper = new DeviceFlowHelper();
629
+ this.apiHelper = new ApiHelper();
630
+ }
631
+ async getPlaylist(path, auth, _resolution) {
632
+ const caps = this.getCapabilities();
633
+ if (caps.presentations) {
634
+ const plan = await this.getPresentations(path, auth);
635
+ if (plan) return presentationsToPlaylist(plan);
636
+ }
637
+ return null;
638
+ }
639
+ async getInstructions(path, auth) {
640
+ const caps = this.getCapabilities();
641
+ if (caps.expandedInstructions) {
642
+ const expanded = await this.getExpandedInstructions(path, auth);
643
+ if (expanded) return collapseInstructions(expanded);
644
+ }
645
+ if (caps.presentations) {
646
+ const plan = await this.getPresentations(path, auth);
647
+ if (plan) return presentationsToInstructions(plan);
648
+ }
649
+ return null;
650
+ }
651
+ async getExpandedInstructions(path, auth) {
652
+ const caps = this.getCapabilities();
653
+ if (caps.presentations) {
654
+ const plan = await this.getPresentations(path, auth);
655
+ if (plan) return presentationsToExpandedInstructions(plan);
656
+ }
657
+ return null;
658
+ }
659
+ requiresAuth() {
660
+ return !!this.config.clientId;
661
+ }
662
+ getCapabilities() {
663
+ return { browse: true, presentations: false, playlist: false, instructions: false, expandedInstructions: false, mediaLicensing: false };
664
+ }
665
+ checkMediaLicense(_mediaId, _auth) {
666
+ return Promise.resolve(null);
667
+ }
668
+ getAuthTypes() {
669
+ if (!this.requiresAuth()) return ["none"];
670
+ const types = ["oauth_pkce"];
671
+ if (this.supportsDeviceFlow()) types.push("device_flow");
672
+ return types;
673
+ }
674
+ // Token management - delegated to TokenHelper
675
+ isAuthValid(auth) {
676
+ return this.tokenHelper.isAuthValid(auth);
677
+ }
678
+ isTokenExpired(auth) {
679
+ return this.tokenHelper.isTokenExpired(auth);
680
+ }
681
+ async refreshToken(auth) {
682
+ return this.tokenHelper.refreshToken(this.config, auth);
683
+ }
684
+ // OAuth PKCE - delegated to OAuthHelper
685
+ generateCodeVerifier() {
686
+ return this.oauthHelper.generateCodeVerifier();
687
+ }
688
+ async generateCodeChallenge(verifier) {
689
+ return this.oauthHelper.generateCodeChallenge(verifier);
690
+ }
691
+ async buildAuthUrl(codeVerifier, redirectUri, state) {
692
+ return this.oauthHelper.buildAuthUrl(this.config, codeVerifier, redirectUri, state || this.id);
693
+ }
694
+ async exchangeCodeForTokens(code, codeVerifier, redirectUri) {
695
+ return this.oauthHelper.exchangeCodeForTokens(this.config, this.id, code, codeVerifier, redirectUri);
696
+ }
697
+ // Device flow - delegated to DeviceFlowHelper
698
+ supportsDeviceFlow() {
699
+ return this.deviceFlowHelper.supportsDeviceFlow(this.config);
700
+ }
701
+ async initiateDeviceFlow() {
702
+ return this.deviceFlowHelper.initiateDeviceFlow(this.config);
703
+ }
704
+ async pollDeviceFlowToken(deviceCode) {
705
+ return this.deviceFlowHelper.pollDeviceFlowToken(this.config, deviceCode);
706
+ }
707
+ calculatePollDelay(baseInterval = 5, slowDownCount = 0) {
708
+ return this.deviceFlowHelper.calculatePollDelay(baseInterval, slowDownCount);
709
+ }
710
+ // API requests - delegated to ApiHelper
711
+ createAuthHeaders(auth) {
712
+ return this.apiHelper.createAuthHeaders(auth);
713
+ }
714
+ async apiRequest(path, auth, method = "GET", body) {
715
+ return this.apiHelper.apiRequest(this.config, this.id, path, auth, method, body);
716
+ }
717
+ // Content factories
718
+ createFolder(id, title, path, image, providerData, isLeaf) {
719
+ return { type: "folder", id, title, path, image, isLeaf, providerData };
720
+ }
404
721
  createFile(id, title, url, options) {
405
- return {
406
- type: "file",
407
- id,
408
- title,
409
- url,
410
- mediaType: options?.mediaType ?? detectMediaType(url),
411
- image: options?.image,
412
- muxPlaybackId: options?.muxPlaybackId,
413
- providerData: options?.providerData
414
- };
722
+ return { type: "file", id, title, url, mediaType: options?.mediaType ?? detectMediaType(url), image: options?.image, muxPlaybackId: options?.muxPlaybackId, providerData: options?.providerData };
415
723
  }
416
724
  };
417
725
 
418
- // src/providers/APlayProvider.ts
419
- var APlayProvider = class extends ContentProvider {
726
+ // src/providers/aPlay/APlayProvider.ts
727
+ var APlayProvider = class {
420
728
  constructor() {
421
- super(...arguments);
729
+ this.apiHelper = new ApiHelper();
422
730
  this.id = "aplay";
423
731
  this.name = "APlay";
424
- this.logos = {
425
- light: "https://www.joinamazing.com/_assets/v11/3ba846c5afd7e73d27bc4d87b63d423e7ae2dc73.svg",
426
- dark: "https://www.joinamazing.com/_assets/v11/3ba846c5afd7e73d27bc4d87b63d423e7ae2dc73.svg"
427
- };
428
- this.config = {
429
- id: "aplay",
430
- name: "APlay",
431
- apiBase: "https://api-prod.amazingkids.app",
432
- oauthBase: "https://api.joinamazing.com/prod/aims/oauth",
433
- clientId: "xFJFq7yNYuXXXMx0YBiQ",
434
- scopes: ["openid", "profile", "email"],
435
- endpoints: {
436
- modules: "/prod/curriculum/modules",
437
- productLibraries: (productId) => `/prod/curriculum/modules/products/${productId}/libraries`,
438
- libraryMedia: (libraryId) => `/prod/creators/libraries/${libraryId}/media`
439
- }
440
- };
732
+ this.logos = { light: "https://www.joinamazing.com/_assets/v11/3ba846c5afd7e73d27bc4d87b63d423e7ae2dc73.svg", dark: "https://www.joinamazing.com/_assets/v11/3ba846c5afd7e73d27bc4d87b63d423e7ae2dc73.svg" };
733
+ this.config = { id: "aplay", name: "APlay", apiBase: "https://api-prod.amazingkids.app", oauthBase: "https://api.joinamazing.com/prod/aims/oauth", clientId: "xFJFq7yNYuXXXMx0YBiQ", scopes: ["openid", "profile", "email"], endpoints: { modules: "/prod/curriculum/modules", productLibraries: (productId) => `/prod/curriculum/modules/products/${productId}/libraries`, libraryMedia: (libraryId) => `/prod/creators/libraries/${libraryId}/media` } };
734
+ this.requiresAuth = true;
735
+ this.authTypes = ["oauth_pkce"];
736
+ this.capabilities = { browse: true, presentations: true, playlist: false, instructions: false, expandedInstructions: false, mediaLicensing: true };
441
737
  }
442
- getCapabilities() {
443
- return {
444
- browse: true,
445
- presentations: true,
446
- playlist: false,
447
- instructions: false,
448
- expandedInstructions: false,
449
- mediaLicensing: true
450
- };
738
+ async apiRequest(path, auth) {
739
+ return this.apiHelper.apiRequest(this.config, this.id, path, auth);
451
740
  }
452
- async browse(folder, auth) {
453
- console.log(`APlay browse called with folder:`, folder ? { id: folder.id, level: folder.providerData?.level } : "null");
454
- console.log(`APlay browse auth present:`, !!auth);
455
- if (!folder) {
456
- console.log(`APlay fetching modules from: ${this.config.endpoints.modules}`);
457
- const response = await this.apiRequest(this.config.endpoints.modules, auth);
458
- console.log(`APlay modules response:`, response ? "received" : "null");
459
- if (!response) return [];
460
- const modules = response.data || response.modules || response;
461
- console.log(`APlay modules count:`, Array.isArray(modules) ? modules.length : "not an array");
462
- if (!Array.isArray(modules)) return [];
463
- const items = [];
464
- for (const m of modules) {
465
- if (m.isLocked) continue;
466
- const allProducts = m.products || [];
467
- const products = allProducts.filter((p) => !p.isHidden);
468
- if (products.length === 0) {
469
- items.push({
470
- type: "folder",
471
- id: m.id || m.moduleId,
472
- title: m.title || m.name,
473
- image: m.image,
474
- providerData: { level: "libraries", productId: m.id || m.moduleId }
475
- });
476
- } else if (products.length === 1) {
477
- const product = products[0];
478
- items.push({
479
- type: "folder",
480
- id: product.productId || product.id,
481
- title: m.title || m.name,
482
- image: m.image || product.image,
483
- providerData: { level: "libraries", productId: product.productId || product.id }
484
- });
485
- } else {
486
- items.push({
487
- type: "folder",
488
- id: m.id || m.moduleId,
489
- title: m.title || m.name,
490
- image: m.image,
491
- providerData: {
492
- level: "products",
493
- products: products.map((p) => ({ id: p.productId || p.id, title: p.title || p.name, image: p.image }))
494
- }
495
- });
496
- }
497
- }
498
- return items;
741
+ async browse(path, auth) {
742
+ const { segments, depth } = parsePath(path);
743
+ console.log("APlay browse called with path:", path, "depth:", depth);
744
+ if (depth === 0) {
745
+ return [{
746
+ type: "folder",
747
+ id: "modules-root",
748
+ title: "Modules",
749
+ path: "/modules"
750
+ }];
499
751
  }
500
- const level = folder.providerData?.level;
501
- switch (level) {
502
- case "products":
503
- return this.getProductFolders(folder);
504
- case "libraries":
505
- return this.getLibraryFolders(folder, auth);
506
- case "media":
507
- return this.getMediaFiles(folder, auth);
508
- default:
509
- return [];
752
+ const root = segments[0];
753
+ if (root !== "modules") return [];
754
+ if (depth === 1) {
755
+ return this.getModules(auth);
756
+ }
757
+ if (depth === 2) {
758
+ const moduleId = segments[1];
759
+ return this.getModuleContent(moduleId, path, auth);
760
+ }
761
+ if (depth === 4 && segments[2] === "products") {
762
+ const productId = segments[3];
763
+ return this.getLibraryFolders(productId, path, auth);
764
+ }
765
+ if (depth === 5 && segments[2] === "products") {
766
+ const libraryId = segments[4];
767
+ return this.getMediaFiles(libraryId, auth);
768
+ }
769
+ if (depth === 4 && segments[2] === "libraries") {
770
+ const libraryId = segments[3];
771
+ return this.getMediaFiles(libraryId, auth);
772
+ }
773
+ return [];
774
+ }
775
+ async getModules(auth) {
776
+ console.log(`APlay fetching modules from: ${this.config.endpoints.modules}`);
777
+ const response = await this.apiRequest(this.config.endpoints.modules, auth);
778
+ console.log("APlay modules response:", response ? "received" : "null");
779
+ if (!response) return [];
780
+ const modules = response.data || response.modules || response;
781
+ console.log("APlay modules count:", Array.isArray(modules) ? modules.length : "not an array");
782
+ if (!Array.isArray(modules)) return [];
783
+ const items = [];
784
+ for (const m of modules) {
785
+ if (m.isLocked) continue;
786
+ const moduleId = m.id || m.moduleId;
787
+ const moduleTitle = m.title || m.name;
788
+ const moduleImage = m.image;
789
+ const allProducts = m.products || [];
790
+ const products = allProducts.filter((p) => !p.isHidden);
791
+ if (products.length === 0) {
792
+ items.push({
793
+ type: "folder",
794
+ id: moduleId,
795
+ title: moduleTitle,
796
+ image: moduleImage,
797
+ path: `/modules/${moduleId}`,
798
+ providerData: { productCount: 0 }
799
+ });
800
+ } else if (products.length === 1) {
801
+ const product = products[0];
802
+ items.push({
803
+ type: "folder",
804
+ id: product.productId || product.id,
805
+ title: moduleTitle,
806
+ image: moduleImage || product.image,
807
+ path: `/modules/${moduleId}`,
808
+ providerData: { productCount: 1, productId: product.productId || product.id }
809
+ });
810
+ } else {
811
+ items.push({
812
+ type: "folder",
813
+ id: moduleId,
814
+ title: moduleTitle,
815
+ image: moduleImage,
816
+ path: `/modules/${moduleId}`,
817
+ providerData: {
818
+ productCount: products.length,
819
+ products: products.map((p) => ({
820
+ id: p.productId || p.id,
821
+ title: p.title || p.name,
822
+ image: p.image
823
+ }))
824
+ }
825
+ });
826
+ }
510
827
  }
828
+ return items;
511
829
  }
512
- getProductFolders(folder) {
513
- const products = folder.providerData?.products || [];
514
- return products.map((p) => ({
515
- type: "folder",
516
- id: p.id,
517
- title: p.title,
518
- image: p.image,
519
- providerData: { level: "libraries", productId: p.id }
520
- }));
830
+ async getModuleContent(moduleId, currentPath, auth) {
831
+ const modules = await this.getModules(auth);
832
+ const module2 = modules.find((m) => m.id === moduleId || m.providerData?.productId === moduleId);
833
+ if (!module2) return [];
834
+ const providerData = module2.providerData;
835
+ const productCount = providerData?.productCount || 0;
836
+ if (productCount === 0 || productCount === 1) {
837
+ const productId = providerData?.productId || moduleId;
838
+ return this.getLibraryFolders(productId, `${currentPath}/libraries`, auth);
839
+ } else {
840
+ const products = providerData?.products || [];
841
+ return products.map((p) => ({
842
+ type: "folder",
843
+ id: p.id,
844
+ title: p.title,
845
+ image: p.image,
846
+ path: `${currentPath}/products/${p.id}`
847
+ }));
848
+ }
521
849
  }
522
- async getLibraryFolders(folder, auth) {
523
- const productId = folder.providerData?.productId;
524
- console.log(`APlay getLibraryFolders called with productId:`, productId);
525
- if (!productId) return [];
850
+ async getLibraryFolders(productId, currentPath, auth) {
851
+ console.log("APlay getLibraryFolders called with productId:", productId);
526
852
  const pathFn = this.config.endpoints.productLibraries;
527
- const path = pathFn(productId);
528
- console.log(`APlay fetching libraries from: ${path}`);
529
- const response = await this.apiRequest(path, auth);
530
- console.log(`APlay libraries response:`, response ? "received" : "null");
853
+ const apiPath = pathFn(productId);
854
+ console.log(`APlay fetching libraries from: ${apiPath}`);
855
+ const response = await this.apiRequest(apiPath, auth);
856
+ console.log("APlay libraries response:", response ? "received" : "null");
531
857
  if (!response) return [];
532
858
  const libraries = response.data || response.libraries || response;
533
- console.log(`APlay libraries count:`, Array.isArray(libraries) ? libraries.length : "not an array");
859
+ console.log("APlay libraries count:", Array.isArray(libraries) ? libraries.length : "not an array");
534
860
  if (!Array.isArray(libraries)) return [];
535
861
  return libraries.map((l) => ({
536
862
  type: "folder",
537
863
  id: l.libraryId || l.id,
538
864
  title: l.title || l.name,
539
865
  image: l.image,
540
- providerData: { level: "media", libraryId: l.libraryId || l.id }
866
+ isLeaf: true,
867
+ path: `${currentPath}/${l.libraryId || l.id}`
541
868
  }));
542
869
  }
543
- async getMediaFiles(folder, auth) {
544
- const libraryId = folder.providerData?.libraryId;
545
- console.log(`APlay getMediaFiles called with libraryId:`, libraryId);
546
- if (!libraryId) return [];
870
+ async getMediaFiles(libraryId, auth) {
871
+ console.log("APlay getMediaFiles called with libraryId:", libraryId);
547
872
  const pathFn = this.config.endpoints.libraryMedia;
548
- const path = pathFn(libraryId);
549
- console.log(`APlay fetching media from: ${path}`);
550
- const response = await this.apiRequest(path, auth);
551
- console.log(`APlay media response:`, response ? "received" : "null");
873
+ const apiPath = pathFn(libraryId);
874
+ console.log(`APlay fetching media from: ${apiPath}`);
875
+ const response = await this.apiRequest(apiPath, auth);
876
+ console.log("APlay media response:", response ? "received" : "null");
552
877
  if (!response) return [];
553
878
  const mediaItems = response.data || response.media || response;
554
879
  if (!Array.isArray(mediaItems)) return [];
@@ -578,144 +903,97 @@ var APlayProvider = class extends ContentProvider {
578
903
  if (!url) continue;
579
904
  const detectedMediaType = detectMediaType(url, mediaType);
580
905
  const fileId = item.mediaId || item.id;
581
- files.push({
582
- type: "file",
583
- id: fileId,
584
- title: item.title || item.name || item.fileName || "",
585
- mediaType: detectedMediaType,
586
- image: thumbnail,
587
- url,
588
- muxPlaybackId,
589
- mediaId: fileId
590
- });
906
+ files.push({ type: "file", id: fileId, title: item.title || item.name || item.fileName || "", mediaType: detectedMediaType, image: thumbnail, url, muxPlaybackId, mediaId: fileId });
591
907
  }
592
908
  return files;
593
909
  }
594
- async getPresentations(folder, auth) {
595
- const libraryId = folder.providerData?.libraryId;
596
- if (!libraryId) return null;
597
- const files = await this.getMediaFiles(folder, auth);
910
+ async getPresentations(path, auth) {
911
+ const { segments, depth } = parsePath(path);
912
+ if (depth < 4 || segments[0] !== "modules") return null;
913
+ let libraryId;
914
+ const title = "Library";
915
+ if (segments[2] === "products" && depth === 5) {
916
+ libraryId = segments[4];
917
+ } else if (segments[2] === "libraries" && depth === 4) {
918
+ libraryId = segments[3];
919
+ } else {
920
+ return null;
921
+ }
922
+ const files = await this.getMediaFiles(libraryId, auth);
598
923
  if (files.length === 0) return null;
599
- const presentations = files.map((f) => ({
600
- id: f.id,
601
- name: f.title,
602
- actionType: "play",
603
- files: [f]
604
- }));
605
- return {
606
- id: libraryId,
607
- name: folder.title,
608
- image: folder.image,
609
- sections: [{
610
- id: `section-${libraryId}`,
611
- name: folder.title || "Library",
612
- presentations
613
- }],
614
- allFiles: files
615
- };
924
+ const presentations = files.map((f) => ({ id: f.id, name: f.title, actionType: "play", files: [f] }));
925
+ return { id: libraryId, name: title, sections: [{ id: `section-${libraryId}`, name: title, presentations }], allFiles: files };
616
926
  }
617
- /**
618
- * Check the license status for a specific media item.
619
- * Returns license information including pingback URL if licensed.
620
- */
621
927
  async checkMediaLicense(mediaId, auth) {
622
928
  if (!auth) return null;
623
929
  try {
624
930
  const url = `${this.config.apiBase}/prod/reports/media/license-check`;
625
- const response = await fetch(url, {
626
- method: "POST",
627
- headers: {
628
- "Authorization": `Bearer ${auth.access_token}`,
629
- "Content-Type": "application/json",
630
- "Accept": "application/json"
631
- },
632
- body: JSON.stringify({ mediaIds: [mediaId] })
633
- });
931
+ const response = await fetch(url, { method: "POST", headers: { "Authorization": `Bearer ${auth.access_token}`, "Content-Type": "application/json", "Accept": "application/json" }, body: JSON.stringify({ mediaIds: [mediaId] }) });
634
932
  if (!response.ok) return null;
635
933
  const data = await response.json();
636
934
  const licenseData = Array.isArray(data) ? data : data.data || [];
637
935
  const result = licenseData.find((item) => item.mediaId === mediaId);
638
936
  if (result?.isLicensed) {
639
- const pingbackUrl = `${this.config.apiBase}/prod/reports/media/${mediaId}/stream-count?source=aplay-pro`;
640
- return {
641
- mediaId,
642
- status: "valid",
643
- message: "Media is licensed for playback",
644
- expiresAt: result.expiresAt
645
- };
937
+ return { mediaId, status: "valid", message: "Media is licensed for playback", expiresAt: result.expiresAt };
646
938
  }
647
- return {
648
- mediaId,
649
- status: "not_licensed",
650
- message: "Media is not licensed"
651
- };
939
+ return { mediaId, status: "not_licensed", message: "Media is not licensed" };
652
940
  } catch {
653
- return {
654
- mediaId,
655
- status: "unknown",
656
- message: "Unable to verify license status"
657
- };
941
+ return { mediaId, status: "unknown", message: "Unable to verify license status" };
658
942
  }
659
943
  }
660
944
  };
661
945
 
662
- // src/providers/SignPresenterProvider.ts
663
- var SignPresenterProvider = class extends ContentProvider {
946
+ // src/providers/signPresenter/SignPresenterProvider.ts
947
+ var SignPresenterProvider = class {
664
948
  constructor() {
665
- super(...arguments);
949
+ this.apiHelper = new ApiHelper();
666
950
  this.id = "signpresenter";
667
951
  this.name = "SignPresenter";
668
- this.logos = {
669
- light: "https://signpresenter.com/files/shared/images/logo.png",
670
- dark: "https://signpresenter.com/files/shared/images/logo.png"
671
- };
672
- this.config = {
673
- id: "signpresenter",
674
- name: "SignPresenter",
675
- apiBase: "https://api.signpresenter.com",
676
- oauthBase: "https://api.signpresenter.com/oauth",
677
- clientId: "lessonsscreen-tv",
678
- scopes: ["openid", "profile", "content"],
679
- supportsDeviceFlow: true,
680
- deviceAuthEndpoint: "/device/authorize",
681
- endpoints: {
682
- playlists: "/content/playlists",
683
- messages: (playlistId) => `/content/playlists/${playlistId}/messages`
684
- }
685
- };
952
+ this.logos = { light: "https://signpresenter.com/files/shared/images/logo.png", dark: "https://signpresenter.com/files/shared/images/logo.png" };
953
+ this.config = { id: "signpresenter", name: "SignPresenter", apiBase: "https://api.signpresenter.com", oauthBase: "https://api.signpresenter.com/oauth", clientId: "lessonsscreen-tv", scopes: ["openid", "profile", "content"], supportsDeviceFlow: true, deviceAuthEndpoint: "/device/authorize", endpoints: { playlists: "/content/playlists", messages: (playlistId) => `/content/playlists/${playlistId}/messages` } };
954
+ this.requiresAuth = true;
955
+ this.authTypes = ["oauth_pkce", "device_flow"];
956
+ this.capabilities = { browse: true, presentations: true, playlist: false, instructions: false, expandedInstructions: false, mediaLicensing: false };
686
957
  }
687
- getCapabilities() {
688
- return {
689
- browse: true,
690
- presentations: true,
691
- playlist: false,
692
- instructions: false,
693
- expandedInstructions: false,
694
- mediaLicensing: false
695
- };
958
+ async apiRequest(path, auth) {
959
+ return this.apiHelper.apiRequest(this.config, this.id, path, auth);
696
960
  }
697
- async browse(folder, auth) {
698
- if (!folder) {
699
- const path = this.config.endpoints.playlists;
700
- const response = await this.apiRequest(path, auth);
701
- if (!response) return [];
702
- const playlists = Array.isArray(response) ? response : response.data || response.playlists || [];
703
- if (!Array.isArray(playlists)) return [];
704
- return playlists.map((p) => ({
961
+ async browse(path, auth) {
962
+ const { segments, depth } = parsePath(path);
963
+ if (depth === 0) {
964
+ return [{
705
965
  type: "folder",
706
- id: p.id,
707
- title: p.name,
708
- image: p.image,
709
- providerData: { level: "messages", playlistId: p.id }
710
- }));
966
+ id: "playlists-root",
967
+ title: "Playlists",
968
+ path: "/playlists"
969
+ }];
970
+ }
971
+ const root = segments[0];
972
+ if (root !== "playlists") return [];
973
+ if (depth === 1) {
974
+ return this.getPlaylists(auth);
975
+ }
976
+ if (depth === 2) {
977
+ const playlistId = segments[1];
978
+ return this.getMessages(playlistId, auth);
711
979
  }
712
- const level = folder.providerData?.level;
713
- if (level === "messages") return this.getMessages(folder, auth);
714
980
  return [];
715
981
  }
716
- async getMessages(folder, auth) {
717
- const playlistId = folder.providerData?.playlistId;
718
- if (!playlistId) return [];
982
+ async getPlaylists(auth) {
983
+ const apiPath = this.config.endpoints.playlists;
984
+ const response = await this.apiRequest(apiPath, auth);
985
+ if (!response) return [];
986
+ const playlists = Array.isArray(response) ? response : response.data || response.playlists || [];
987
+ if (!Array.isArray(playlists)) return [];
988
+ return playlists.map((p) => ({
989
+ type: "folder",
990
+ id: p.id,
991
+ title: p.name,
992
+ image: p.image,
993
+ path: `/playlists/${p.id}`
994
+ }));
995
+ }
996
+ async getMessages(playlistId, auth) {
719
997
  const pathFn = this.config.endpoints.messages;
720
998
  const response = await this.apiRequest(pathFn(playlistId), auth);
721
999
  if (!response) return [];
@@ -726,93 +1004,42 @@ var SignPresenterProvider = class extends ContentProvider {
726
1004
  if (!msg.url) continue;
727
1005
  const url = msg.url;
728
1006
  const seconds = msg.seconds;
729
- files.push({
730
- type: "file",
731
- id: msg.id,
732
- title: msg.name,
733
- mediaType: detectMediaType(url, msg.mediaType),
734
- image: msg.thumbnail || msg.image,
735
- url,
736
- // For direct media providers, embedUrl is the media URL itself
737
- embedUrl: url,
738
- providerData: seconds !== void 0 ? { seconds } : void 0
739
- });
1007
+ files.push({ type: "file", id: msg.id, title: msg.name, mediaType: detectMediaType(url, msg.mediaType), image: msg.thumbnail || msg.image, url, embedUrl: url, providerData: seconds !== void 0 ? { seconds } : void 0 });
740
1008
  }
741
1009
  return files;
742
1010
  }
743
- async getPresentations(folder, auth) {
744
- const playlistId = folder.providerData?.playlistId;
745
- if (!playlistId) return null;
746
- const files = await this.getMessages(folder, auth);
1011
+ async getPresentations(path, auth) {
1012
+ const { segments, depth } = parsePath(path);
1013
+ if (depth < 2 || segments[0] !== "playlists") return null;
1014
+ const playlistId = segments[1];
1015
+ const files = await this.getMessages(playlistId, auth);
747
1016
  if (files.length === 0) return null;
748
- const presentations = files.map((f) => ({
749
- id: f.id,
750
- name: f.title,
751
- actionType: "play",
752
- files: [f]
753
- }));
754
- return {
755
- id: playlistId,
756
- name: folder.title,
757
- image: folder.image,
758
- sections: [{
759
- id: `section-${playlistId}`,
760
- name: folder.title || "Playlist",
761
- presentations
762
- }],
763
- allFiles: files
764
- };
1017
+ const playlists = await this.getPlaylists(auth);
1018
+ const playlist = playlists.find((p) => p.id === playlistId);
1019
+ const title = playlist?.title || "Playlist";
1020
+ const image = playlist?.image;
1021
+ const presentations = files.map((f) => ({ id: f.id, name: f.title, actionType: "play", files: [f] }));
1022
+ return { id: playlistId, name: title, image, sections: [{ id: `section-${playlistId}`, name: title, presentations }], allFiles: files };
765
1023
  }
766
1024
  };
767
1025
 
768
- // src/providers/LessonsChurchProvider.ts
769
- var LessonsChurchProvider = class extends ContentProvider {
1026
+ // src/providers/lessonsChurch/LessonsChurchProvider.ts
1027
+ var LessonsChurchProvider = class {
770
1028
  constructor() {
771
- super(...arguments);
772
1029
  this.id = "lessonschurch";
773
1030
  this.name = "Lessons.church";
774
- this.logos = {
775
- light: "https://lessons.church/images/logo.png",
776
- dark: "https://lessons.church/images/logo-dark.png"
777
- };
778
- this.config = {
779
- id: "lessonschurch",
780
- name: "Lessons.church",
781
- apiBase: "https://api.lessons.church",
782
- oauthBase: "",
783
- clientId: "",
784
- scopes: [],
785
- endpoints: {
786
- programs: "/programs/public",
787
- studies: (programId) => `/studies/public/program/${programId}`,
788
- lessons: (studyId) => `/lessons/public/study/${studyId}`,
789
- venues: (lessonId) => `/venues/public/lesson/${lessonId}`,
790
- playlist: (venueId) => `/venues/playlist/${venueId}`,
791
- feed: (venueId) => `/venues/public/feed/${venueId}`,
792
- addOns: "/addOns/public",
793
- addOnDetail: (id) => `/addOns/public/${id}`
794
- }
795
- };
1031
+ this.logos = { light: "https://lessons.church/images/logo.png", dark: "https://lessons.church/images/logo-dark.png" };
1032
+ this.config = { id: "lessonschurch", name: "Lessons.church", apiBase: "https://api.lessons.church", oauthBase: "", clientId: "", scopes: [], endpoints: { programs: "/programs/public", studies: (programId) => `/studies/public/program/${programId}`, lessons: (studyId) => `/lessons/public/study/${studyId}`, venues: (lessonId) => `/venues/public/lesson/${lessonId}`, playlist: (venueId) => `/venues/playlist/${venueId}`, feed: (venueId) => `/venues/public/feed/${venueId}`, addOns: "/addOns/public", addOnDetail: (id) => `/addOns/public/${id}` } };
1033
+ this.requiresAuth = false;
1034
+ this.authTypes = ["none"];
1035
+ this.capabilities = { browse: true, presentations: true, playlist: true, instructions: true, expandedInstructions: true, mediaLicensing: false };
796
1036
  }
797
- requiresAuth() {
798
- return false;
799
- }
800
- getCapabilities() {
801
- return {
802
- browse: true,
803
- presentations: true,
804
- playlist: true,
805
- instructions: true,
806
- expandedInstructions: true,
807
- mediaLicensing: false
808
- };
809
- }
810
- async getPlaylist(folder, _auth, resolution) {
811
- const venueId = folder.providerData?.venueId;
1037
+ async getPlaylist(path, _auth, resolution) {
1038
+ const venueId = getSegment(path, 4);
812
1039
  if (!venueId) return null;
813
- let path = `/venues/playlist/${venueId}`;
814
- if (resolution) path += `?resolution=${resolution}`;
815
- const response = await this.apiRequest(path);
1040
+ let apiPath = `/venues/playlist/${venueId}`;
1041
+ if (resolution) apiPath += `?resolution=${resolution}`;
1042
+ const response = await this.apiRequest(apiPath);
816
1043
  if (!response) return null;
817
1044
  const files = [];
818
1045
  const messages = response.messages || [];
@@ -824,15 +1051,7 @@ var LessonsChurchProvider = class extends ContentProvider {
824
1051
  if (!f.url) continue;
825
1052
  const url = f.url;
826
1053
  const fileId = f.id || `playlist-${fileIndex++}`;
827
- files.push({
828
- type: "file",
829
- id: fileId,
830
- title: f.name || msg.name,
831
- mediaType: detectMediaType(url, f.fileType),
832
- image: response.lessonImage,
833
- url,
834
- providerData: { seconds: f.seconds, loop: f.loop, loopVideo: f.loopVideo }
835
- });
1054
+ files.push({ type: "file", id: fileId, title: f.name || msg.name, mediaType: detectMediaType(url, f.fileType), image: response.lessonImage, url, providerData: { seconds: f.seconds, loop: f.loop, loopVideo: f.loopVideo } });
836
1055
  }
837
1056
  }
838
1057
  return files;
@@ -847,48 +1066,32 @@ var LessonsChurchProvider = class extends ContentProvider {
847
1066
  return null;
848
1067
  }
849
1068
  }
850
- async browse(folder, _auth, resolution) {
851
- if (!folder) {
1069
+ async browse(path, _auth) {
1070
+ const { segments, depth } = parsePath(path);
1071
+ console.log("[LessonsChurchProvider.browse] path:", path, "depth:", depth, "segments:", segments);
1072
+ if (depth === 0) {
852
1073
  return [
853
- {
854
- type: "folder",
855
- id: "lessons-root",
856
- title: "Lessons",
857
- providerData: { level: "programs" }
858
- },
859
- {
860
- type: "folder",
861
- id: "addons-root",
862
- title: "Add-Ons",
863
- providerData: { level: "addOnCategories" }
864
- }
1074
+ { type: "folder", id: "lessons-root", title: "Lessons", path: "/lessons" },
1075
+ { type: "folder", id: "addons-root", title: "Add-Ons", path: "/addons" }
865
1076
  ];
866
1077
  }
867
- const level = folder.providerData?.level;
868
- switch (level) {
869
- // Lessons hierarchy
870
- case "programs":
871
- return this.getPrograms();
872
- case "studies":
873
- return this.getStudies(folder);
874
- case "lessons":
875
- return this.getLessons(folder);
876
- case "venues":
877
- return this.getVenues(folder);
878
- case "playlist":
879
- return this.getPlaylistFiles(folder, resolution);
880
- // Add-ons hierarchy
881
- case "addOnCategories":
882
- return this.getAddOnCategories();
883
- case "addOns":
884
- return this.getAddOnsByCategory(folder);
885
- default:
886
- return [];
887
- }
1078
+ const root = segments[0];
1079
+ if (root === "lessons") return this.browseLessons(path, segments);
1080
+ if (root === "addons") return this.browseAddOns(path, segments);
1081
+ return [];
1082
+ }
1083
+ async browseLessons(currentPath, segments) {
1084
+ const depth = segments.length;
1085
+ if (depth === 1) return this.getPrograms();
1086
+ if (depth === 2) return this.getStudies(segments[1], currentPath);
1087
+ if (depth === 3) return this.getLessons(segments[2], currentPath);
1088
+ if (depth === 4) return this.getVenues(segments[3], currentPath);
1089
+ if (depth === 5) return this.getPlaylistFiles(segments[4]);
1090
+ return [];
888
1091
  }
889
1092
  async getPrograms() {
890
- const path = this.config.endpoints.programs;
891
- const response = await this.apiRequest(path);
1093
+ const apiPath = this.config.endpoints.programs;
1094
+ const response = await this.apiRequest(apiPath);
892
1095
  if (!response) return [];
893
1096
  const programs = Array.isArray(response) ? response : [];
894
1097
  return programs.map((p) => ({
@@ -896,12 +1099,10 @@ var LessonsChurchProvider = class extends ContentProvider {
896
1099
  id: p.id,
897
1100
  title: p.name,
898
1101
  image: p.image,
899
- providerData: { level: "studies", programId: p.id }
1102
+ path: `/lessons/${p.id}`
900
1103
  }));
901
1104
  }
902
- async getStudies(folder) {
903
- const programId = folder.providerData?.programId;
904
- if (!programId) return [];
1105
+ async getStudies(programId, currentPath) {
905
1106
  const pathFn = this.config.endpoints.studies;
906
1107
  const response = await this.apiRequest(pathFn(programId));
907
1108
  if (!response) return [];
@@ -911,12 +1112,10 @@ var LessonsChurchProvider = class extends ContentProvider {
911
1112
  id: s.id,
912
1113
  title: s.name,
913
1114
  image: s.image,
914
- providerData: { level: "lessons", studyId: s.id }
1115
+ path: `${currentPath}/${s.id}`
915
1116
  }));
916
1117
  }
917
- async getLessons(folder) {
918
- const studyId = folder.providerData?.studyId;
919
- if (!studyId) return [];
1118
+ async getLessons(studyId, currentPath) {
920
1119
  const pathFn = this.config.endpoints.lessons;
921
1120
  const response = await this.apiRequest(pathFn(studyId));
922
1121
  if (!response) return [];
@@ -926,31 +1125,42 @@ var LessonsChurchProvider = class extends ContentProvider {
926
1125
  id: l.id,
927
1126
  title: l.name || l.title,
928
1127
  image: l.image,
929
- providerData: { level: "venues", lessonId: l.id, lessonImage: l.image }
1128
+ path: `${currentPath}/${l.id}`,
1129
+ providerData: { lessonImage: l.image }
1130
+ // Keep for display on venues
930
1131
  }));
931
1132
  }
932
- async getVenues(folder) {
933
- const lessonId = folder.providerData?.lessonId;
934
- if (!lessonId) return [];
1133
+ async getVenues(lessonId, currentPath) {
935
1134
  const pathFn = this.config.endpoints.venues;
936
1135
  const response = await this.apiRequest(pathFn(lessonId));
937
1136
  if (!response) return [];
1137
+ const lessonResponse = await this.apiRequest(`/lessons/public/${lessonId}`);
1138
+ const lessonImage = lessonResponse?.image;
938
1139
  const venues = Array.isArray(response) ? response : [];
939
- return venues.map((v) => ({
1140
+ const result = venues.map((v) => ({
940
1141
  type: "folder",
941
1142
  id: v.id,
942
1143
  title: v.name,
943
- image: folder.providerData?.lessonImage,
944
- providerData: { level: "playlist", venueId: v.id }
1144
+ image: lessonImage,
1145
+ isLeaf: true,
1146
+ path: `${currentPath}/${v.id}`
945
1147
  }));
1148
+ console.log("[LessonsChurchProvider.getVenues] returning:", result.map((r) => ({ id: r.id, title: r.title, isLeaf: r.isLeaf })));
1149
+ return result;
946
1150
  }
947
- async getPlaylistFiles(folder, resolution) {
948
- const files = await this.getPlaylist(folder, null, resolution);
1151
+ async getPlaylistFiles(venueId) {
1152
+ const files = await this.getPlaylist(`/lessons/_/_/_/${venueId}`, null);
949
1153
  return files || [];
950
1154
  }
1155
+ async browseAddOns(_currentPath, segments) {
1156
+ const depth = segments.length;
1157
+ if (depth === 1) return this.getAddOnCategories();
1158
+ if (depth === 2) return this.getAddOnsByCategory(segments[1]);
1159
+ return [];
1160
+ }
951
1161
  async getAddOnCategories() {
952
- const path = this.config.endpoints.addOns;
953
- const response = await this.apiRequest(path);
1162
+ const apiPath = this.config.endpoints.addOns;
1163
+ const response = await this.apiRequest(apiPath);
954
1164
  if (!response) return [];
955
1165
  const addOns = Array.isArray(response) ? response : [];
956
1166
  const categories = Array.from(new Set(addOns.map((a) => a.category).filter(Boolean)));
@@ -958,17 +1168,16 @@ var LessonsChurchProvider = class extends ContentProvider {
958
1168
  type: "folder",
959
1169
  id: `category-${category}`,
960
1170
  title: category,
961
- providerData: {
962
- level: "addOns",
963
- category,
964
- allAddOns: addOns
965
- }
1171
+ path: `/addons/${encodeURIComponent(category)}`
966
1172
  }));
967
1173
  }
968
- async getAddOnsByCategory(folder) {
969
- const category = folder.providerData?.category;
970
- const allAddOns = folder.providerData?.allAddOns || [];
971
- const filtered = allAddOns.filter((a) => a.category === category);
1174
+ async getAddOnsByCategory(category) {
1175
+ const decodedCategory = decodeURIComponent(category);
1176
+ const apiPath = this.config.endpoints.addOns;
1177
+ const response = await this.apiRequest(apiPath);
1178
+ if (!response) return [];
1179
+ const allAddOns = Array.isArray(response) ? response : [];
1180
+ const filtered = allAddOns.filter((a) => a.category === decodedCategory);
972
1181
  const files = [];
973
1182
  for (const addOn of filtered) {
974
1183
  const file = await this.convertAddOnToFile(addOn);
@@ -978,8 +1187,8 @@ var LessonsChurchProvider = class extends ContentProvider {
978
1187
  }
979
1188
  async convertAddOnToFile(addOn) {
980
1189
  const pathFn = this.config.endpoints.addOnDetail;
981
- const path = pathFn(addOn.id);
982
- const detail = await this.apiRequest(path);
1190
+ const apiPath = pathFn(addOn.id);
1191
+ const detail = await this.apiRequest(apiPath);
983
1192
  if (!detail) return null;
984
1193
  let url = "";
985
1194
  let mediaType = "video";
@@ -994,53 +1203,32 @@ var LessonsChurchProvider = class extends ContentProvider {
994
1203
  const fileType = file.fileType;
995
1204
  mediaType = fileType?.startsWith("video/") ? "video" : "image";
996
1205
  } else {
997
- return null;
998
- }
999
- return {
1000
- type: "file",
1001
- id: addOn.id,
1002
- title: addOn.name,
1003
- mediaType,
1004
- image: addOn.image,
1005
- url,
1006
- embedUrl: `https://lessons.church/embed/addon/${addOn.id}`,
1007
- providerData: {
1008
- seconds,
1009
- loopVideo: video?.loopVideo || false
1010
- }
1011
- };
1206
+ return null;
1207
+ }
1208
+ return { type: "file", id: addOn.id, title: addOn.name, mediaType, image: addOn.image, url, embedUrl: `https://lessons.church/embed/addon/${addOn.id}`, providerData: { seconds, loopVideo: video?.loopVideo || false } };
1012
1209
  }
1013
- async getPresentations(folder, _auth, resolution) {
1014
- const venueId = folder.providerData?.venueId;
1210
+ async getPresentations(path, _auth) {
1211
+ const venueId = getSegment(path, 4);
1015
1212
  if (!venueId) return null;
1016
- let path = `/venues/public/feed/${venueId}`;
1017
- if (resolution) path += `?resolution=${resolution}`;
1018
- const venueData = await this.apiRequest(path);
1213
+ const apiPath = `/venues/public/feed/${venueId}`;
1214
+ const venueData = await this.apiRequest(apiPath);
1019
1215
  if (!venueData) return null;
1020
1216
  return this.convertVenueToPlan(venueData);
1021
1217
  }
1022
- async getInstructions(folder, _auth) {
1023
- const venueId = folder.providerData?.venueId;
1218
+ async getInstructions(path, _auth) {
1219
+ const venueId = getSegment(path, 4);
1024
1220
  if (!venueId) return null;
1025
1221
  const response = await this.apiRequest(`/venues/public/planItems/${venueId}`);
1026
1222
  if (!response) return null;
1027
- const processItem = (item) => ({
1028
- id: item.id,
1029
- itemType: item.itemType,
1030
- relatedId: item.relatedId,
1031
- label: item.label,
1032
- description: item.description,
1033
- seconds: item.seconds,
1034
- children: item.children?.map(processItem),
1035
- embedUrl: this.getEmbedUrl(item.itemType, item.relatedId)
1036
- });
1037
- return {
1038
- venueName: response.venueName,
1039
- items: (response.items || []).map(processItem)
1223
+ const processItem = (item) => {
1224
+ const itemType = this.normalizeItemType(item.itemType);
1225
+ const relatedId = item.relatedId;
1226
+ return { id: item.id, itemType, relatedId, label: item.label, description: item.description, seconds: item.seconds, children: item.children?.map(processItem), embedUrl: this.getEmbedUrl(itemType, relatedId) };
1040
1227
  };
1228
+ return { venueName: response.venueName, items: (response.items || []).map(processItem) };
1041
1229
  }
1042
- async getExpandedInstructions(folder, _auth) {
1043
- const venueId = folder.providerData?.venueId;
1230
+ async getExpandedInstructions(path, _auth) {
1231
+ const venueId = getSegment(path, 4);
1044
1232
  if (!venueId) return null;
1045
1233
  const [planItemsResponse, actionsResponse] = await Promise.all([
1046
1234
  this.apiRequest(`/venues/public/planItems/${venueId}`),
@@ -1051,66 +1239,47 @@ var LessonsChurchProvider = class extends ContentProvider {
1051
1239
  if (actionsResponse?.sections) {
1052
1240
  for (const section of actionsResponse.sections) {
1053
1241
  if (section.id && section.actions) {
1054
- sectionActionsMap.set(section.id, section.actions.map((action) => ({
1055
- id: action.id,
1056
- itemType: "lessonAction",
1057
- relatedId: action.id,
1058
- label: action.name,
1059
- description: action.actionType,
1060
- seconds: action.seconds,
1061
- embedUrl: this.getEmbedUrl("lessonAction", action.id)
1062
- })));
1242
+ sectionActionsMap.set(section.id, section.actions.map((action) => {
1243
+ const embedUrl = this.getEmbedUrl("action", action.id);
1244
+ return { id: action.id, itemType: "action", relatedId: action.id, label: action.name, description: action.actionType, seconds: action.seconds, children: [{ id: action.id + "-file", itemType: "file", label: action.name, seconds: action.seconds, embedUrl }] };
1245
+ }));
1063
1246
  }
1064
1247
  }
1065
1248
  }
1066
1249
  const processItem = (item) => {
1067
1250
  const relatedId = item.relatedId;
1068
- const itemType = item.itemType;
1251
+ const itemType = this.normalizeItemType(item.itemType);
1069
1252
  const children = item.children;
1070
1253
  let processedChildren;
1071
1254
  if (children) {
1072
1255
  processedChildren = children.map((child) => {
1073
1256
  const childRelatedId = child.relatedId;
1257
+ const childItemType = this.normalizeItemType(child.itemType);
1074
1258
  if (childRelatedId && sectionActionsMap.has(childRelatedId)) {
1075
- return {
1076
- id: child.id,
1077
- itemType: child.itemType,
1078
- relatedId: childRelatedId,
1079
- label: child.label,
1080
- description: child.description,
1081
- seconds: child.seconds,
1082
- children: sectionActionsMap.get(childRelatedId),
1083
- embedUrl: this.getEmbedUrl(child.itemType, childRelatedId)
1084
- };
1259
+ return { id: child.id, itemType: childItemType, relatedId: childRelatedId, label: child.label, description: child.description, seconds: child.seconds, children: sectionActionsMap.get(childRelatedId), embedUrl: this.getEmbedUrl(childItemType, childRelatedId) };
1085
1260
  }
1086
1261
  return processItem(child);
1087
1262
  });
1088
1263
  }
1089
- return {
1090
- id: item.id,
1091
- itemType,
1092
- relatedId,
1093
- label: item.label,
1094
- description: item.description,
1095
- seconds: item.seconds,
1096
- children: processedChildren,
1097
- embedUrl: this.getEmbedUrl(itemType, relatedId)
1098
- };
1099
- };
1100
- return {
1101
- venueName: planItemsResponse.venueName,
1102
- items: (planItemsResponse.items || []).map(processItem)
1264
+ return { id: item.id, itemType, relatedId, label: item.label, description: item.description, seconds: item.seconds, children: processedChildren, embedUrl: this.getEmbedUrl(itemType, relatedId) };
1103
1265
  };
1266
+ return { venueName: planItemsResponse.venueName, items: (planItemsResponse.items || []).map(processItem) };
1267
+ }
1268
+ normalizeItemType(type) {
1269
+ if (type === "lessonSection") return "section";
1270
+ if (type === "lessonAction") return "action";
1271
+ if (type === "lessonAddOn") return "addon";
1272
+ return type;
1104
1273
  }
1105
1274
  getEmbedUrl(itemType, relatedId) {
1106
1275
  if (!relatedId) return void 0;
1107
1276
  const baseUrl = "https://lessons.church";
1108
1277
  switch (itemType) {
1109
- case "lessonAction":
1278
+ case "action":
1110
1279
  return `${baseUrl}/embed/action/${relatedId}`;
1111
- case "lessonAddOn":
1280
+ case "addon":
1112
1281
  return `${baseUrl}/embed/addon/${relatedId}`;
1113
- case "lessonSection":
1282
+ case "section":
1114
1283
  return `${baseUrl}/embed/section/${relatedId}`;
1115
1284
  default:
1116
1285
  return void 0;
@@ -1128,16 +1297,7 @@ var LessonsChurchProvider = class extends ContentProvider {
1128
1297
  for (const file of action.files || []) {
1129
1298
  if (!file.url) continue;
1130
1299
  const embedUrl = action.id ? `https://lessons.church/embed/action/${action.id}` : void 0;
1131
- const contentFile = {
1132
- type: "file",
1133
- id: file.id || "",
1134
- title: file.name || "",
1135
- mediaType: detectMediaType(file.url, file.fileType),
1136
- image: venue.lessonImage,
1137
- url: file.url,
1138
- embedUrl,
1139
- providerData: { seconds: file.seconds, streamUrl: file.streamUrl }
1140
- };
1300
+ const contentFile = { type: "file", id: file.id || "", title: file.name || "", mediaType: detectMediaType(file.url, file.fileType), image: venue.lessonImage, url: file.url, embedUrl, providerData: { seconds: file.seconds, streamUrl: file.streamUrl } };
1141
1301
  files.push(contentFile);
1142
1302
  allFiles.push(contentFile);
1143
1303
  }
@@ -1149,51 +1309,69 @@ var LessonsChurchProvider = class extends ContentProvider {
1149
1309
  sections.push({ id: section.id || "", name: section.name || "Untitled Section", presentations });
1150
1310
  }
1151
1311
  }
1152
- return {
1153
- id: venue.id || "",
1154
- name: venue.lessonName || venue.name || "Plan",
1155
- description: venue.lessonDescription,
1156
- image: venue.lessonImage,
1157
- sections,
1158
- allFiles
1159
- };
1312
+ return { id: venue.id || "", name: venue.lessonName || venue.name || "Plan", description: venue.lessonDescription, image: venue.lessonImage, sections, allFiles };
1160
1313
  }
1161
1314
  };
1162
1315
 
1163
- // src/providers/b1church/auth.ts
1164
- function buildB1AuthUrl(config, appBase, redirectUri, state) {
1316
+ // src/providers/b1Church/auth.ts
1317
+ async function generateCodeChallenge(verifier) {
1318
+ const encoder = new TextEncoder();
1319
+ const data = encoder.encode(verifier);
1320
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
1321
+ const hashArray = new Uint8Array(hashBuffer);
1322
+ let binary = "";
1323
+ for (let i = 0; i < hashArray.length; i++) {
1324
+ binary += String.fromCharCode(hashArray[i]);
1325
+ }
1326
+ const base64 = btoa(binary);
1327
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1328
+ }
1329
+ async function buildB1AuthUrl(config, appBase, redirectUri, codeVerifier, state) {
1330
+ const codeChallenge = await generateCodeChallenge(codeVerifier);
1165
1331
  const oauthParams = new URLSearchParams({
1332
+ response_type: "code",
1166
1333
  client_id: config.clientId,
1167
1334
  redirect_uri: redirectUri,
1168
- response_type: "code",
1169
- scope: config.scopes.join(" ")
1335
+ scope: config.scopes.join(" "),
1336
+ code_challenge: codeChallenge,
1337
+ code_challenge_method: "S256",
1338
+ state: state || ""
1170
1339
  });
1171
- if (state) {
1172
- oauthParams.set("state", state);
1340
+ const url = `${appBase}/oauth?${oauthParams.toString()}`;
1341
+ return { url, challengeMethod: "S256" };
1342
+ }
1343
+ async function exchangeCodeForTokensWithPKCE(config, code, redirectUri, codeVerifier) {
1344
+ try {
1345
+ const params = { grant_type: "authorization_code", code, client_id: config.clientId, code_verifier: codeVerifier, redirect_uri: redirectUri };
1346
+ const tokenUrl = `${config.oauthBase}/token`;
1347
+ console.log(`B1Church PKCE token exchange request to: ${tokenUrl}`);
1348
+ console.log(` - client_id: ${config.clientId}`);
1349
+ console.log(` - redirect_uri: ${redirectUri}`);
1350
+ console.log(` - code: ${code.substring(0, 10)}...`);
1351
+ const response = await fetch(tokenUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params) });
1352
+ console.log(`B1Church token response status: ${response.status}`);
1353
+ if (!response.ok) {
1354
+ const errorText = await response.text();
1355
+ console.error(`B1Church token exchange failed: ${response.status} - ${errorText}`);
1356
+ return null;
1357
+ }
1358
+ const data = await response.json();
1359
+ console.log(`B1Church token exchange successful, got access_token: ${!!data.access_token}`);
1360
+ return { access_token: data.access_token, refresh_token: data.refresh_token, token_type: data.token_type || "Bearer", created_at: Math.floor(Date.now() / 1e3), expires_in: data.expires_in, scope: data.scope || config.scopes.join(" ") };
1361
+ } catch (error) {
1362
+ console.error("B1Church token exchange error:", error);
1363
+ return null;
1173
1364
  }
1174
- const returnUrl = `/oauth?${oauthParams.toString()}`;
1175
- const url = `${appBase}/login?returnUrl=${encodeURIComponent(returnUrl)}`;
1176
- return { url, challengeMethod: "none" };
1177
1365
  }
1178
1366
  async function exchangeCodeForTokensWithSecret(config, code, redirectUri, clientSecret) {
1179
1367
  try {
1180
- const params = {
1181
- grant_type: "authorization_code",
1182
- code,
1183
- client_id: config.clientId,
1184
- client_secret: clientSecret,
1185
- redirect_uri: redirectUri
1186
- };
1368
+ const params = { grant_type: "authorization_code", code, client_id: config.clientId, client_secret: clientSecret, redirect_uri: redirectUri };
1187
1369
  const tokenUrl = `${config.oauthBase}/token`;
1188
1370
  console.log(`B1Church token exchange request to: ${tokenUrl}`);
1189
1371
  console.log(` - client_id: ${config.clientId}`);
1190
1372
  console.log(` - redirect_uri: ${redirectUri}`);
1191
1373
  console.log(` - code: ${code.substring(0, 10)}...`);
1192
- const response = await fetch(tokenUrl, {
1193
- method: "POST",
1194
- headers: { "Content-Type": "application/json" },
1195
- body: JSON.stringify(params)
1196
- });
1374
+ const response = await fetch(tokenUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params) });
1197
1375
  console.log(`B1Church token response status: ${response.status}`);
1198
1376
  if (!response.ok) {
1199
1377
  const errorText = await response.text();
@@ -1202,14 +1380,7 @@ async function exchangeCodeForTokensWithSecret(config, code, redirectUri, client
1202
1380
  }
1203
1381
  const data = await response.json();
1204
1382
  console.log(`B1Church token exchange successful, got access_token: ${!!data.access_token}`);
1205
- return {
1206
- access_token: data.access_token,
1207
- refresh_token: data.refresh_token,
1208
- token_type: data.token_type || "Bearer",
1209
- created_at: Math.floor(Date.now() / 1e3),
1210
- expires_in: data.expires_in,
1211
- scope: data.scope || config.scopes.join(" ")
1212
- };
1383
+ return { access_token: data.access_token, refresh_token: data.refresh_token, token_type: data.token_type || "Bearer", created_at: Math.floor(Date.now() / 1e3), expires_in: data.expires_in, scope: data.scope || config.scopes.join(" ") };
1213
1384
  } catch (error) {
1214
1385
  console.error("B1Church token exchange error:", error);
1215
1386
  return null;
@@ -1218,27 +1389,11 @@ async function exchangeCodeForTokensWithSecret(config, code, redirectUri, client
1218
1389
  async function refreshTokenWithSecret(config, auth, clientSecret) {
1219
1390
  if (!auth.refresh_token) return null;
1220
1391
  try {
1221
- const params = {
1222
- grant_type: "refresh_token",
1223
- refresh_token: auth.refresh_token,
1224
- client_id: config.clientId,
1225
- client_secret: clientSecret
1226
- };
1227
- const response = await fetch(`${config.oauthBase}/token`, {
1228
- method: "POST",
1229
- headers: { "Content-Type": "application/json" },
1230
- body: JSON.stringify(params)
1231
- });
1392
+ const params = { grant_type: "refresh_token", refresh_token: auth.refresh_token, client_id: config.clientId, client_secret: clientSecret };
1393
+ const response = await fetch(`${config.oauthBase}/token`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params) });
1232
1394
  if (!response.ok) return null;
1233
1395
  const data = await response.json();
1234
- return {
1235
- access_token: data.access_token,
1236
- refresh_token: data.refresh_token || auth.refresh_token,
1237
- token_type: data.token_type || "Bearer",
1238
- created_at: Math.floor(Date.now() / 1e3),
1239
- expires_in: data.expires_in,
1240
- scope: data.scope || auth.scope
1241
- };
1396
+ return { access_token: data.access_token, refresh_token: data.refresh_token || auth.refresh_token, token_type: data.token_type || "Bearer", created_at: Math.floor(Date.now() / 1e3), expires_in: data.expires_in, scope: data.scope || auth.scope };
1242
1397
  } catch {
1243
1398
  return null;
1244
1399
  }
@@ -1246,14 +1401,7 @@ async function refreshTokenWithSecret(config, auth, clientSecret) {
1246
1401
  async function initiateDeviceFlow(config) {
1247
1402
  if (!config.supportsDeviceFlow || !config.deviceAuthEndpoint) return null;
1248
1403
  try {
1249
- const response = await fetch(`${config.oauthBase}${config.deviceAuthEndpoint}`, {
1250
- method: "POST",
1251
- headers: { "Content-Type": "application/json" },
1252
- body: JSON.stringify({
1253
- client_id: config.clientId,
1254
- scope: config.scopes.join(" ")
1255
- })
1256
- });
1404
+ const response = await fetch(`${config.oauthBase}${config.deviceAuthEndpoint}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_id: config.clientId, scope: config.scopes.join(" ") }) });
1257
1405
  if (!response.ok) {
1258
1406
  const errorText = await response.text();
1259
1407
  console.error(`B1Church device authorize failed: ${response.status} - ${errorText}`);
@@ -1267,25 +1415,10 @@ async function initiateDeviceFlow(config) {
1267
1415
  }
1268
1416
  async function pollDeviceFlowToken(config, deviceCode) {
1269
1417
  try {
1270
- const response = await fetch(`${config.oauthBase}/token`, {
1271
- method: "POST",
1272
- headers: { "Content-Type": "application/json" },
1273
- body: JSON.stringify({
1274
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
1275
- device_code: deviceCode,
1276
- client_id: config.clientId
1277
- })
1278
- });
1418
+ const response = await fetch(`${config.oauthBase}/token`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: deviceCode, client_id: config.clientId }) });
1279
1419
  if (response.ok) {
1280
1420
  const data = await response.json();
1281
- return {
1282
- access_token: data.access_token,
1283
- refresh_token: data.refresh_token,
1284
- token_type: data.token_type || "Bearer",
1285
- created_at: Math.floor(Date.now() / 1e3),
1286
- expires_in: data.expires_in,
1287
- scope: data.scope || config.scopes.join(" ")
1288
- };
1421
+ return { access_token: data.access_token, refresh_token: data.refresh_token, token_type: data.token_type || "Bearer", created_at: Math.floor(Date.now() / 1e3), expires_in: data.expires_in, scope: data.scope || config.scopes.join(" ") };
1289
1422
  }
1290
1423
  const errorData = await response.json();
1291
1424
  switch (errorData.error) {
@@ -1305,7 +1438,7 @@ async function pollDeviceFlowToken(config, deviceCode) {
1305
1438
  }
1306
1439
  }
1307
1440
 
1308
- // src/providers/b1church/api.ts
1441
+ // src/providers/b1Church/api.ts
1309
1442
  var API_BASE = "https://api.churchapps.org";
1310
1443
  var LESSONS_API_BASE = "https://api.lessons.church";
1311
1444
  var CONTENT_API_BASE = "https://contentapi.churchapps.org";
@@ -1337,10 +1470,7 @@ async function fetchPlans(planTypeId, auth) {
1337
1470
  async function fetchVenueFeed(venueId) {
1338
1471
  try {
1339
1472
  const url = `${LESSONS_API_BASE}/venues/public/feed/${venueId}`;
1340
- const response = await fetch(url, {
1341
- method: "GET",
1342
- headers: { Accept: "application/json" }
1343
- });
1473
+ const response = await fetch(url, { method: "GET", headers: { Accept: "application/json" } });
1344
1474
  if (!response.ok) return null;
1345
1475
  return await response.json();
1346
1476
  } catch {
@@ -1350,9 +1480,29 @@ async function fetchVenueFeed(venueId) {
1350
1480
  async function fetchArrangementKey(churchId, arrangementId) {
1351
1481
  try {
1352
1482
  const url = `${CONTENT_API_BASE}/arrangementKeys/presenter/${churchId}/${arrangementId}`;
1483
+ const response = await fetch(url, { method: "GET", headers: { Accept: "application/json" } });
1484
+ if (!response.ok) return null;
1485
+ return await response.json();
1486
+ } catch {
1487
+ return null;
1488
+ }
1489
+ }
1490
+ async function fetchFromProviderProxy(method, ministryId, providerId, path, authData, resolution) {
1491
+ try {
1492
+ const url = `${API_BASE}/doing/providerProxy/${method}`;
1493
+ const headers = {
1494
+ "Content-Type": "application/json",
1495
+ Accept: "application/json"
1496
+ };
1497
+ if (authData) {
1498
+ headers["Authorization"] = `Bearer ${authData.access_token}`;
1499
+ }
1500
+ const body = { ministryId, providerId, path };
1501
+ if (resolution !== void 0) body.resolution = resolution;
1353
1502
  const response = await fetch(url, {
1354
- method: "GET",
1355
- headers: { Accept: "application/json" }
1503
+ method: "POST",
1504
+ headers,
1505
+ body: JSON.stringify(body)
1356
1506
  });
1357
1507
  if (!response.ok) return null;
1358
1508
  return await response.json();
@@ -1361,107 +1511,39 @@ async function fetchArrangementKey(churchId, arrangementId) {
1361
1511
  }
1362
1512
  }
1363
1513
 
1364
- // src/providers/b1church/converters.ts
1514
+ // src/providers/b1Church/converters.ts
1365
1515
  function ministryToFolder(ministry) {
1366
- return {
1367
- type: "folder",
1368
- id: ministry.id,
1369
- title: ministry.name,
1370
- image: ministry.photoUrl,
1371
- providerData: {
1372
- level: "ministry",
1373
- ministryId: ministry.id,
1374
- churchId: ministry.churchId
1375
- }
1376
- };
1516
+ return { type: "folder", id: ministry.id, title: ministry.name, path: "", image: ministry.photoUrl, providerData: { level: "ministry", ministryId: ministry.id, churchId: ministry.churchId } };
1377
1517
  }
1378
1518
  function planTypeToFolder(planType, ministryId) {
1379
- return {
1380
- type: "folder",
1381
- id: planType.id,
1382
- title: planType.name,
1383
- providerData: {
1384
- level: "planType",
1385
- planTypeId: planType.id,
1386
- ministryId,
1387
- churchId: planType.churchId
1388
- }
1389
- };
1519
+ return { type: "folder", id: planType.id, title: planType.name, path: "", providerData: { level: "planType", planTypeId: planType.id, ministryId, churchId: planType.churchId } };
1390
1520
  }
1391
1521
  function planToFolder(plan) {
1392
- return {
1393
- type: "folder",
1394
- id: plan.id,
1395
- title: plan.name,
1396
- providerData: {
1397
- isLeaf: true,
1398
- level: "plan",
1399
- planId: plan.id,
1400
- planTypeId: plan.planTypeId,
1401
- ministryId: plan.ministryId,
1402
- churchId: plan.churchId,
1403
- serviceDate: plan.serviceDate,
1404
- contentType: plan.contentType,
1405
- contentId: plan.contentId
1406
- }
1407
- };
1522
+ return { type: "folder", id: plan.id, title: plan.name, path: "", isLeaf: true, providerData: { level: "plan", planId: plan.id, planTypeId: plan.planTypeId, ministryId: plan.ministryId, churchId: plan.churchId, serviceDate: plan.serviceDate, contentType: plan.contentType, contentId: plan.contentId } };
1408
1523
  }
1409
1524
  async function planItemToPresentation(item, venueFeed) {
1410
1525
  const itemType = item.itemType;
1411
1526
  if (itemType === "arrangementKey" && item.churchId && item.relatedId) {
1412
1527
  const songData = await fetchArrangementKey(item.churchId, item.relatedId);
1413
- if (songData) {
1414
- return arrangementToPresentation(item, songData);
1415
- }
1528
+ if (songData) return arrangementToPresentation(item, songData);
1416
1529
  }
1417
- if ((itemType === "lessonSection" || itemType === "lessonAction" || itemType === "lessonAddOn") && venueFeed) {
1530
+ if ((itemType === "lessonSection" || itemType === "section" || itemType === "lessonAction" || itemType === "action" || itemType === "lessonAddOn" || itemType === "addon") && venueFeed) {
1418
1531
  const files = getFilesFromVenueFeed(venueFeed, itemType, item.relatedId);
1419
- if (files.length > 0) {
1420
- return {
1421
- id: item.id,
1422
- name: item.label || "Lesson Content",
1423
- actionType: itemType === "lessonAddOn" ? "add-on" : "play",
1424
- files
1425
- };
1426
- }
1532
+ if (files.length > 0) return { id: item.id, name: item.label || "Lesson Content", actionType: itemType === "lessonAddOn" || itemType === "addon" ? "add-on" : "play", files };
1427
1533
  }
1428
1534
  if (itemType === "item" || itemType === "header") {
1429
- return {
1430
- id: item.id,
1431
- name: item.label || "",
1432
- actionType: "other",
1433
- files: [],
1434
- providerData: {
1435
- itemType,
1436
- description: item.description,
1437
- seconds: item.seconds
1438
- }
1439
- };
1535
+ return { id: item.id, name: item.label || "", actionType: "other", files: [], providerData: { itemType, description: item.description, seconds: item.seconds } };
1440
1536
  }
1441
1537
  return null;
1442
1538
  }
1443
1539
  function arrangementToPresentation(item, songData) {
1444
1540
  const title = songData.songDetail?.title || item.label || "Song";
1445
- return {
1446
- id: item.id,
1447
- name: title,
1448
- actionType: "other",
1449
- files: [],
1450
- providerData: {
1451
- itemType: "song",
1452
- title,
1453
- artist: songData.songDetail?.artist,
1454
- lyrics: songData.arrangement?.lyrics,
1455
- keySignature: songData.arrangementKey?.keySignature,
1456
- arrangementName: songData.arrangement?.name,
1457
- seconds: songData.songDetail?.seconds || item.seconds
1458
- }
1459
- };
1541
+ return { id: item.id, name: title, actionType: "other", files: [], providerData: { itemType: "song", title, artist: songData.songDetail?.artist, lyrics: songData.arrangement?.lyrics, keySignature: songData.arrangementKey?.keySignature, arrangementName: songData.arrangement?.name, seconds: songData.songDetail?.seconds || item.seconds } };
1460
1542
  }
1461
1543
  function getFilesFromVenueFeed(venueFeed, itemType, relatedId) {
1462
1544
  const files = [];
1463
1545
  if (!relatedId) return files;
1464
- if (itemType === "lessonSection") {
1546
+ if (itemType === "lessonSection" || itemType === "section") {
1465
1547
  for (const section of venueFeed.sections || []) {
1466
1548
  if (section.id === relatedId) {
1467
1549
  for (const action of section.actions || []) {
@@ -1473,7 +1555,7 @@ function getFilesFromVenueFeed(venueFeed, itemType, relatedId) {
1473
1555
  break;
1474
1556
  }
1475
1557
  }
1476
- } else if (itemType === "lessonAction") {
1558
+ } else if (itemType === "lessonAction" || itemType === "action") {
1477
1559
  for (const section of venueFeed.sections || []) {
1478
1560
  for (const action of section.actions || []) {
1479
1561
  if (action.id === relatedId) {
@@ -1486,75 +1568,51 @@ function getFilesFromVenueFeed(venueFeed, itemType, relatedId) {
1486
1568
  return files;
1487
1569
  }
1488
1570
  function convertFeedFiles(feedFiles, thumbnailImage) {
1489
- return feedFiles.filter((f) => f.url).map((f) => ({
1490
- type: "file",
1491
- id: f.id || "",
1492
- title: f.name || "",
1493
- mediaType: detectMediaType(f.url || "", f.fileType),
1494
- image: thumbnailImage,
1495
- url: f.url || "",
1496
- providerData: { seconds: f.seconds, streamUrl: f.streamUrl }
1497
- }));
1571
+ return feedFiles.filter((f) => f.url).map((f) => ({ type: "file", id: f.id || "", title: f.name || "", mediaType: detectMediaType(f.url || "", f.fileType), image: thumbnailImage, url: f.url || "", providerData: { seconds: f.seconds, streamUrl: f.streamUrl } }));
1498
1572
  }
1499
1573
  function planItemToInstruction(item) {
1500
- return {
1501
- id: item.id,
1502
- itemType: item.itemType,
1503
- relatedId: item.relatedId,
1504
- label: item.label,
1505
- description: item.description,
1506
- seconds: item.seconds,
1507
- children: item.children?.map(planItemToInstruction)
1508
- };
1574
+ let itemType = item.itemType;
1575
+ switch (item.itemType) {
1576
+ case "lessonSection":
1577
+ itemType = "section";
1578
+ break;
1579
+ case "lessonAction":
1580
+ itemType = "action";
1581
+ break;
1582
+ case "lessonAddOn":
1583
+ itemType = "addon";
1584
+ break;
1585
+ }
1586
+ return { id: item.id, itemType, relatedId: item.relatedId, label: item.label, description: item.description, seconds: item.seconds, children: item.children?.map(planItemToInstruction) };
1509
1587
  }
1510
1588
 
1511
- // src/providers/b1church/B1ChurchProvider.ts
1512
- var B1ChurchProvider = class extends ContentProvider {
1589
+ // src/providers/b1Church/B1ChurchProvider.ts
1590
+ var INTERNAL_PROVIDERS = ["b1church", "lessonschurch"];
1591
+ function isExternalProviderItem(item) {
1592
+ if (!item.providerId || INTERNAL_PROVIDERS.includes(item.providerId)) return false;
1593
+ const itemType = item.itemType || "";
1594
+ return itemType.startsWith("provider");
1595
+ }
1596
+ var B1ChurchProvider = class {
1513
1597
  constructor() {
1514
- super(...arguments);
1598
+ this.apiHelper = new ApiHelper();
1515
1599
  this.id = "b1church";
1516
1600
  this.name = "B1.Church";
1517
- this.logos = {
1518
- light: "https://b1.church/b1-church-logo.png",
1519
- dark: "https://b1.church/b1-church-logo.png"
1520
- };
1521
- this.config = {
1522
- id: "b1church",
1523
- name: "B1.Church",
1524
- apiBase: `${API_BASE}/doing`,
1525
- oauthBase: `${API_BASE}/membership/oauth`,
1526
- clientId: "",
1527
- // Consumer must provide client_id
1528
- scopes: ["plans"],
1529
- supportsDeviceFlow: true,
1530
- deviceAuthEndpoint: "/device/authorize",
1531
- endpoints: {
1532
- planItems: (churchId, planId) => `/planItems/presenter/${churchId}/${planId}`
1533
- }
1534
- };
1601
+ this.logos = { light: "https://b1.church/b1-church-logo.png", dark: "https://b1.church/b1-church-logo.png" };
1602
+ this.config = { id: "b1church", name: "B1.Church", apiBase: `${API_BASE}/doing`, oauthBase: `${API_BASE}/membership/oauth`, clientId: "nsowldn58dk", scopes: ["plans"], supportsDeviceFlow: true, deviceAuthEndpoint: "/device/authorize", endpoints: { planItems: (churchId, planId) => `/planItems/presenter/${churchId}/${planId}` } };
1535
1603
  this.appBase = "https://admin.b1.church";
1604
+ this.requiresAuth = true;
1605
+ this.authTypes = ["oauth_pkce", "device_flow"];
1606
+ this.capabilities = { browse: true, presentations: true, playlist: true, instructions: true, expandedInstructions: true, mediaLicensing: false };
1536
1607
  }
1537
- // ============================================================
1538
- // Provider Info
1539
- // ============================================================
1540
- requiresAuth() {
1541
- return true;
1608
+ async apiRequest(path, authData) {
1609
+ return this.apiHelper.apiRequest(this.config, this.id, path, authData);
1542
1610
  }
1543
- getCapabilities() {
1544
- return {
1545
- browse: true,
1546
- presentations: true,
1547
- playlist: true,
1548
- instructions: true,
1549
- expandedInstructions: true,
1550
- mediaLicensing: false
1551
- };
1611
+ async buildAuthUrl(codeVerifier, redirectUri, state) {
1612
+ return buildB1AuthUrl(this.config, this.appBase, redirectUri, codeVerifier, state);
1552
1613
  }
1553
- // ============================================================
1554
- // Authentication
1555
- // ============================================================
1556
- async buildAuthUrl(_codeVerifier, redirectUri, state) {
1557
- return buildB1AuthUrl(this.config, this.appBase, redirectUri, state);
1614
+ async exchangeCodeForTokensWithPKCE(code, redirectUri, codeVerifier) {
1615
+ return exchangeCodeForTokensWithPKCE(this.config, code, redirectUri, codeVerifier);
1558
1616
  }
1559
1617
  async exchangeCodeForTokensWithSecret(code, redirectUri, clientSecret) {
1560
1618
  return exchangeCodeForTokensWithSecret(this.config, code, redirectUri, clientSecret);
@@ -1568,48 +1626,69 @@ var B1ChurchProvider = class extends ContentProvider {
1568
1626
  async pollDeviceFlowToken(deviceCode) {
1569
1627
  return pollDeviceFlowToken(this.config, deviceCode);
1570
1628
  }
1571
- // ============================================================
1572
- // Content Browsing
1573
- // ============================================================
1574
- /**
1575
- * Browse content hierarchy:
1576
- * - Root: List of ministries (groups with "ministry" tag)
1577
- * - Ministry: List of plan types
1578
- * - PlanType: List of plans (leaf nodes)
1579
- *
1580
- * Plans are leaf nodes - use getPresentations(), getPlaylist(), getInstructions()
1581
- * to get plan content.
1582
- */
1583
- async browse(folder, authData) {
1584
- if (!folder) {
1629
+ async browse(path, authData) {
1630
+ const { segments, depth } = parsePath(path);
1631
+ if (depth === 0) {
1632
+ return [{
1633
+ type: "folder",
1634
+ id: "ministries-root",
1635
+ title: "Ministries",
1636
+ path: "/ministries"
1637
+ }];
1638
+ }
1639
+ const root = segments[0];
1640
+ if (root !== "ministries") return [];
1641
+ if (depth === 1) {
1585
1642
  const ministries = await fetchMinistries(authData);
1586
- return ministries.map(ministryToFolder);
1643
+ return ministries.map((m) => {
1644
+ const folder = ministryToFolder(m);
1645
+ const ministryId = folder.providerData?.ministryId || folder.id;
1646
+ return { ...folder, path: `/ministries/${ministryId}` };
1647
+ });
1587
1648
  }
1588
- const level = folder.providerData?.level;
1589
- if (level === "ministry") {
1590
- const ministryId = folder.providerData?.ministryId;
1591
- if (!ministryId) return [];
1649
+ if (depth === 2) {
1650
+ const ministryId = segments[1];
1592
1651
  const planTypes = await fetchPlanTypes(ministryId, authData);
1593
- return planTypes.map((pt) => planTypeToFolder(pt, ministryId));
1652
+ return planTypes.map((pt) => {
1653
+ const folder = planTypeToFolder(pt, ministryId);
1654
+ const planTypeId = folder.providerData?.planTypeId || folder.id;
1655
+ return { ...folder, path: `/ministries/${ministryId}/${planTypeId}` };
1656
+ });
1594
1657
  }
1595
- if (level === "planType") {
1596
- const planTypeId = folder.providerData?.planTypeId;
1597
- if (!planTypeId) return [];
1658
+ if (depth === 3) {
1659
+ const ministryId = segments[1];
1660
+ const planTypeId = segments[2];
1598
1661
  const plans = await fetchPlans(planTypeId, authData);
1599
- return plans.map(planToFolder);
1662
+ return plans.map((p) => {
1663
+ const folder = planToFolder(p);
1664
+ const planId = folder.providerData?.planId || folder.id;
1665
+ return {
1666
+ ...folder,
1667
+ isLeaf: true,
1668
+ path: `/ministries/${ministryId}/${planTypeId}/${planId}`
1669
+ };
1670
+ });
1600
1671
  }
1601
1672
  return [];
1602
1673
  }
1603
- // ============================================================
1604
- // Presentations
1605
- // ============================================================
1606
- async getPresentations(folder, authData) {
1607
- const level = folder.providerData?.level;
1608
- if (level !== "plan") return null;
1609
- const planId = folder.providerData?.planId;
1610
- const churchId = folder.providerData?.churchId;
1611
- const venueId = folder.providerData?.contentId;
1612
- if (!planId || !churchId) return null;
1674
+ async getPresentations(path, authData) {
1675
+ const { segments, depth } = parsePath(path);
1676
+ if (depth < 4 || segments[0] !== "ministries") return null;
1677
+ const ministryId = segments[1];
1678
+ const planId = segments[3];
1679
+ const planTypeId = segments[2];
1680
+ const plans = await fetchPlans(planTypeId, authData);
1681
+ const planFolder = plans.find((p) => {
1682
+ const folder2 = planToFolder(p);
1683
+ return folder2.providerData?.planId === planId || folder2.id === planId;
1684
+ });
1685
+ if (!planFolder) return null;
1686
+ const folder = planToFolder(planFolder);
1687
+ const providerData = folder.providerData;
1688
+ const churchId = providerData?.churchId;
1689
+ const venueId = providerData?.contentId;
1690
+ const planTitle = folder.title || "Plan";
1691
+ if (!churchId) return null;
1613
1692
  const pathFn = this.config.endpoints.planItems;
1614
1693
  const planItems = await this.apiRequest(pathFn(churchId, planId), authData);
1615
1694
  if (!planItems || !Array.isArray(planItems)) return null;
@@ -1619,49 +1698,101 @@ var B1ChurchProvider = class extends ContentProvider {
1619
1698
  for (const sectionItem of planItems) {
1620
1699
  const presentations = [];
1621
1700
  for (const child of sectionItem.children || []) {
1622
- const presentation = await planItemToPresentation(child, venueFeed);
1623
- if (presentation) {
1624
- presentations.push(presentation);
1625
- allFiles.push(...presentation.files);
1701
+ if (isExternalProviderItem(child) && child.providerId && child.providerPath) {
1702
+ const externalPlan = await fetchFromProviderProxy(
1703
+ "getPresentations",
1704
+ ministryId,
1705
+ child.providerId,
1706
+ child.providerPath,
1707
+ authData
1708
+ );
1709
+ if (externalPlan) {
1710
+ for (const section of externalPlan.sections) {
1711
+ presentations.push(...section.presentations);
1712
+ }
1713
+ allFiles.push(...externalPlan.allFiles);
1714
+ }
1715
+ } else {
1716
+ const presentation = await planItemToPresentation(child, venueFeed);
1717
+ if (presentation) {
1718
+ presentations.push(presentation);
1719
+ allFiles.push(...presentation.files);
1720
+ }
1626
1721
  }
1627
1722
  }
1628
1723
  if (presentations.length > 0 || sectionItem.label) {
1629
- sections.push({
1630
- id: sectionItem.id,
1631
- name: sectionItem.label || "Section",
1632
- presentations
1633
- });
1724
+ sections.push({ id: sectionItem.id, name: sectionItem.label || "Section", presentations });
1634
1725
  }
1635
1726
  }
1636
- return { id: planId, name: folder.title, sections, allFiles };
1637
- }
1638
- // ============================================================
1639
- // Instructions
1640
- // ============================================================
1641
- async getInstructions(folder, authData) {
1642
- const level = folder.providerData?.level;
1643
- if (level !== "plan") return null;
1644
- const planId = folder.providerData?.planId;
1645
- const churchId = folder.providerData?.churchId;
1646
- if (!planId || !churchId) return null;
1727
+ return { id: planId, name: planTitle, sections, allFiles };
1728
+ }
1729
+ async getInstructions(path, authData) {
1730
+ const { segments, depth } = parsePath(path);
1731
+ if (depth < 4 || segments[0] !== "ministries") return null;
1732
+ const ministryId = segments[1];
1733
+ const planId = segments[3];
1734
+ const planTypeId = segments[2];
1735
+ const plans = await fetchPlans(planTypeId, authData);
1736
+ const planFolder = plans.find((p) => {
1737
+ const folder2 = planToFolder(p);
1738
+ return folder2.providerData?.planId === planId || folder2.id === planId;
1739
+ });
1740
+ if (!planFolder) return null;
1741
+ const folder = planToFolder(planFolder);
1742
+ const providerData = folder.providerData;
1743
+ const churchId = providerData?.churchId;
1744
+ const planTitle = folder.title || "Plan";
1745
+ if (!churchId) return null;
1647
1746
  const pathFn = this.config.endpoints.planItems;
1648
1747
  const planItems = await this.apiRequest(pathFn(churchId, planId), authData);
1649
1748
  if (!planItems || !Array.isArray(planItems)) return null;
1650
- return {
1651
- venueName: folder.title,
1652
- items: planItems.map(planItemToInstruction)
1653
- };
1749
+ const processedItems = await this.processInstructionItems(planItems, ministryId, authData);
1750
+ return { venueName: planTitle, items: processedItems };
1751
+ }
1752
+ async getExpandedInstructions(path, authData) {
1753
+ return this.getInstructions(path, authData);
1754
+ }
1755
+ async processInstructionItems(items, ministryId, authData) {
1756
+ const result = [];
1757
+ for (const item of items) {
1758
+ if (isExternalProviderItem(item) && item.providerId && item.providerPath) {
1759
+ const externalInstructions = await fetchFromProviderProxy(
1760
+ "getExpandedInstructions",
1761
+ ministryId,
1762
+ item.providerId,
1763
+ item.providerPath,
1764
+ authData
1765
+ );
1766
+ if (externalInstructions) {
1767
+ result.push(...externalInstructions.items);
1768
+ }
1769
+ } else {
1770
+ const instructionItem = planItemToInstruction(item);
1771
+ if (item.children && item.children.length > 0) {
1772
+ instructionItem.children = await this.processInstructionItems(item.children, ministryId, authData);
1773
+ }
1774
+ result.push(instructionItem);
1775
+ }
1776
+ }
1777
+ return result;
1654
1778
  }
1655
- // ============================================================
1656
- // Playlist
1657
- // ============================================================
1658
- async getPlaylist(folder, authData) {
1659
- const level = folder.providerData?.level;
1660
- if (level !== "plan") return [];
1661
- const planId = folder.providerData?.planId;
1662
- const churchId = folder.providerData?.churchId;
1663
- const venueId = folder.providerData?.contentId;
1664
- if (!planId || !churchId) return [];
1779
+ async getPlaylist(path, authData, resolution) {
1780
+ const { segments, depth } = parsePath(path);
1781
+ if (depth < 4 || segments[0] !== "ministries") return [];
1782
+ const ministryId = segments[1];
1783
+ const planId = segments[3];
1784
+ const planTypeId = segments[2];
1785
+ const plans = await fetchPlans(planTypeId, authData);
1786
+ const planFolder = plans.find((p) => {
1787
+ const folder2 = planToFolder(p);
1788
+ return folder2.providerData?.planId === planId || folder2.id === planId;
1789
+ });
1790
+ if (!planFolder) return [];
1791
+ const folder = planToFolder(planFolder);
1792
+ const providerData = folder.providerData;
1793
+ const churchId = providerData?.churchId;
1794
+ const venueId = providerData?.contentId;
1795
+ if (!churchId) return [];
1665
1796
  const pathFn = this.config.endpoints.planItems;
1666
1797
  const planItems = await this.apiRequest(pathFn(churchId, planId), authData);
1667
1798
  if (!planItems || !Array.isArray(planItems)) return [];
@@ -1669,10 +1800,24 @@ var B1ChurchProvider = class extends ContentProvider {
1669
1800
  const files = [];
1670
1801
  for (const sectionItem of planItems) {
1671
1802
  for (const child of sectionItem.children || []) {
1672
- const itemType = child.itemType;
1673
- if ((itemType === "lessonSection" || itemType === "lessonAction" || itemType === "lessonAddOn") && venueFeed) {
1674
- const itemFiles = getFilesFromVenueFeed(venueFeed, itemType, child.relatedId);
1675
- files.push(...itemFiles);
1803
+ if (isExternalProviderItem(child) && child.providerId && child.providerPath) {
1804
+ const externalFiles = await fetchFromProviderProxy(
1805
+ "getPlaylist",
1806
+ ministryId,
1807
+ child.providerId,
1808
+ child.providerPath,
1809
+ authData,
1810
+ resolution
1811
+ );
1812
+ if (externalFiles) {
1813
+ files.push(...externalFiles);
1814
+ }
1815
+ } else {
1816
+ const itemType = child.itemType;
1817
+ if ((itemType === "lessonSection" || itemType === "section" || itemType === "lessonAction" || itemType === "action" || itemType === "lessonAddOn" || itemType === "addon") && venueFeed) {
1818
+ const itemFiles = getFilesFromVenueFeed(venueFeed, itemType, child.relatedId);
1819
+ files.push(...itemFiles);
1820
+ }
1676
1821
  }
1677
1822
  }
1678
1823
  }
@@ -1680,81 +1825,62 @@ var B1ChurchProvider = class extends ContentProvider {
1680
1825
  }
1681
1826
  };
1682
1827
 
1683
- // src/providers/PlanningCenterProvider.ts
1684
- var PlanningCenterProvider = class extends ContentProvider {
1828
+ // src/providers/planningCenter/PlanningCenterProvider.ts
1829
+ var PlanningCenterProvider = class {
1685
1830
  constructor() {
1686
- super(...arguments);
1831
+ this.apiHelper = new ApiHelper();
1687
1832
  this.id = "planningcenter";
1688
1833
  this.name = "Planning Center";
1689
- this.logos = {
1690
- light: "https://www.planningcenter.com/icons/icon-512x512.png",
1691
- dark: "https://www.planningcenter.com/icons/icon-512x512.png"
1692
- };
1693
- // Planning Center uses OAuth 2.0 with PKCE (handled by base ContentProvider class)
1694
- this.config = {
1695
- id: "planningcenter",
1696
- name: "Planning Center",
1697
- apiBase: "https://api.planningcenteronline.com",
1698
- oauthBase: "https://api.planningcenteronline.com/oauth",
1699
- clientId: "",
1700
- // Consumer must provide client_id
1701
- scopes: ["services"],
1702
- endpoints: {
1703
- serviceTypes: "/services/v2/service_types",
1704
- plans: (serviceTypeId) => `/services/v2/service_types/${serviceTypeId}/plans`,
1705
- planItems: (serviceTypeId, planId) => `/services/v2/service_types/${serviceTypeId}/plans/${planId}/items`,
1706
- song: (itemId) => `/services/v2/songs/${itemId}`,
1707
- arrangement: (songId, arrangementId) => `/services/v2/songs/${songId}/arrangements/${arrangementId}`,
1708
- arrangementSections: (songId, arrangementId) => `/services/v2/songs/${songId}/arrangements/${arrangementId}/sections`,
1709
- media: (mediaId) => `/services/v2/media/${mediaId}`,
1710
- mediaAttachments: (mediaId) => `/services/v2/media/${mediaId}/attachments`
1711
- }
1712
- };
1834
+ this.logos = { light: "https://www.planningcenter.com/icons/icon-512x512.png", dark: "https://www.planningcenter.com/icons/icon-512x512.png" };
1835
+ this.config = { id: "planningcenter", name: "Planning Center", apiBase: "https://api.planningcenteronline.com", oauthBase: "https://api.planningcenteronline.com/oauth", clientId: "", scopes: ["services"], endpoints: { serviceTypes: "/services/v2/service_types", plans: (serviceTypeId) => `/services/v2/service_types/${serviceTypeId}/plans`, planItems: (serviceTypeId, planId) => `/services/v2/service_types/${serviceTypeId}/plans/${planId}/items`, song: (itemId) => `/services/v2/songs/${itemId}`, arrangement: (songId, arrangementId) => `/services/v2/songs/${songId}/arrangements/${arrangementId}`, arrangementSections: (songId, arrangementId) => `/services/v2/songs/${songId}/arrangements/${arrangementId}/sections`, media: (mediaId) => `/services/v2/media/${mediaId}`, mediaAttachments: (mediaId) => `/services/v2/media/${mediaId}/attachments` } };
1713
1836
  this.ONE_WEEK_MS = 6048e5;
1837
+ this.requiresAuth = true;
1838
+ this.authTypes = ["oauth_pkce"];
1839
+ this.capabilities = { browse: true, presentations: true, playlist: false, instructions: false, expandedInstructions: false, mediaLicensing: false };
1714
1840
  }
1715
- requiresAuth() {
1716
- return true;
1717
- }
1718
- getCapabilities() {
1719
- return {
1720
- browse: true,
1721
- presentations: true,
1722
- playlist: false,
1723
- instructions: false,
1724
- expandedInstructions: false,
1725
- mediaLicensing: false
1726
- };
1841
+ async apiRequest(path, auth) {
1842
+ return this.apiHelper.apiRequest(this.config, this.id, path, auth);
1727
1843
  }
1728
- async browse(folder, auth) {
1729
- if (!folder) {
1730
- const response = await this.apiRequest(
1731
- this.config.endpoints.serviceTypes,
1732
- auth
1733
- );
1734
- if (!response?.data) return [];
1735
- return response.data.map((serviceType) => ({
1844
+ async browse(path, auth) {
1845
+ const { segments, depth } = parsePath(path);
1846
+ if (depth === 0) {
1847
+ return [{
1736
1848
  type: "folder",
1737
- id: serviceType.id,
1738
- title: serviceType.attributes.name,
1739
- providerData: {
1740
- level: "serviceType",
1741
- serviceTypeId: serviceType.id
1742
- }
1743
- }));
1849
+ id: "serviceTypes-root",
1850
+ title: "Service Types",
1851
+ path: "/serviceTypes"
1852
+ }];
1744
1853
  }
1745
- const level = folder.providerData?.level;
1746
- switch (level) {
1747
- case "serviceType":
1748
- return this.getPlans(folder, auth);
1749
- case "plan":
1750
- return this.getPlanItems(folder, auth);
1751
- default:
1752
- return [];
1854
+ const root = segments[0];
1855
+ if (root !== "serviceTypes") return [];
1856
+ if (depth === 1) {
1857
+ return this.getServiceTypes(auth);
1753
1858
  }
1859
+ if (depth === 2) {
1860
+ const serviceTypeId = segments[1];
1861
+ return this.getPlans(serviceTypeId, path, auth);
1862
+ }
1863
+ if (depth === 3) {
1864
+ const serviceTypeId = segments[1];
1865
+ const planId = segments[2];
1866
+ return this.getPlanItems(serviceTypeId, planId, auth);
1867
+ }
1868
+ return [];
1869
+ }
1870
+ async getServiceTypes(auth) {
1871
+ const response = await this.apiRequest(
1872
+ this.config.endpoints.serviceTypes,
1873
+ auth
1874
+ );
1875
+ if (!response?.data) return [];
1876
+ return response.data.map((serviceType) => ({
1877
+ type: "folder",
1878
+ id: serviceType.id,
1879
+ title: serviceType.attributes.name,
1880
+ path: `/serviceTypes/${serviceType.id}`
1881
+ }));
1754
1882
  }
1755
- async getPlans(folder, auth) {
1756
- const serviceTypeId = folder.providerData?.serviceTypeId;
1757
- if (!serviceTypeId) return [];
1883
+ async getPlans(serviceTypeId, currentPath, auth) {
1758
1884
  const pathFn = this.config.endpoints.plans;
1759
1885
  const response = await this.apiRequest(
1760
1886
  `${pathFn(serviceTypeId)}?filter=future&order=sort_date`,
@@ -1771,73 +1897,46 @@ var PlanningCenterProvider = class extends ContentProvider {
1771
1897
  type: "folder",
1772
1898
  id: plan.id,
1773
1899
  title: plan.attributes.title || this.formatDate(plan.attributes.sort_date),
1774
- providerData: {
1775
- level: "plan",
1776
- serviceTypeId,
1777
- planId: plan.id,
1778
- sortDate: plan.attributes.sort_date
1779
- }
1900
+ isLeaf: true,
1901
+ path: `${currentPath}/${plan.id}`,
1902
+ providerData: { sortDate: plan.attributes.sort_date }
1780
1903
  }));
1781
1904
  }
1782
- async getPlanItems(folder, auth) {
1783
- const serviceTypeId = folder.providerData?.serviceTypeId;
1784
- const planId = folder.providerData?.planId;
1785
- if (!serviceTypeId || !planId) return [];
1905
+ async getPlanItems(serviceTypeId, planId, auth) {
1786
1906
  const pathFn = this.config.endpoints.planItems;
1787
1907
  const response = await this.apiRequest(
1788
1908
  `${pathFn(serviceTypeId, planId)}?per_page=100`,
1789
1909
  auth
1790
1910
  );
1791
1911
  if (!response?.data) return [];
1792
- return response.data.map((item) => ({
1793
- type: "file",
1794
- id: item.id,
1795
- title: item.attributes.title || "",
1796
- mediaType: "image",
1797
- url: "",
1798
- providerData: {
1799
- itemType: item.attributes.item_type,
1800
- description: item.attributes.description,
1801
- length: item.attributes.length,
1802
- songId: item.relationships?.song?.data?.id,
1803
- arrangementId: item.relationships?.arrangement?.data?.id
1804
- }
1805
- }));
1912
+ return response.data.map((item) => ({ type: "file", id: item.id, title: item.attributes.title || "", mediaType: "image", url: "", providerData: { itemType: item.attributes.item_type, description: item.attributes.description, length: item.attributes.length, songId: item.relationships?.song?.data?.id, arrangementId: item.relationships?.arrangement?.data?.id } }));
1806
1913
  }
1807
- async getPresentations(folder, auth) {
1808
- const level = folder.providerData?.level;
1809
- if (level !== "plan") return null;
1810
- const serviceTypeId = folder.providerData?.serviceTypeId;
1811
- const planId = folder.providerData?.planId;
1812
- if (!serviceTypeId || !planId) return null;
1914
+ async getPresentations(path, auth) {
1915
+ const { segments, depth } = parsePath(path);
1916
+ if (depth < 3 || segments[0] !== "serviceTypes") return null;
1917
+ const serviceTypeId = segments[1];
1918
+ const planId = segments[2];
1813
1919
  const pathFn = this.config.endpoints.planItems;
1814
1920
  const response = await this.apiRequest(
1815
1921
  `${pathFn(serviceTypeId, planId)}?per_page=100`,
1816
1922
  auth
1817
1923
  );
1818
1924
  if (!response?.data) return null;
1925
+ const plans = await this.getPlans(serviceTypeId, `/serviceTypes/${serviceTypeId}`, auth);
1926
+ const plan = plans.find((p) => p.id === planId);
1927
+ const planTitle = plan?.title || "Plan";
1819
1928
  const sections = [];
1820
1929
  const allFiles = [];
1821
1930
  let currentSection = null;
1822
1931
  for (const item of response.data) {
1823
1932
  const itemType = item.attributes.item_type;
1824
1933
  if (itemType === "header") {
1825
- if (currentSection && currentSection.presentations.length > 0) {
1826
- sections.push(currentSection);
1827
- }
1828
- currentSection = {
1829
- id: item.id,
1830
- name: item.attributes.title || "Section",
1831
- presentations: []
1832
- };
1934
+ if (currentSection && currentSection.presentations.length > 0) sections.push(currentSection);
1935
+ currentSection = { id: item.id, name: item.attributes.title || "Section", presentations: [] };
1833
1936
  continue;
1834
1937
  }
1835
1938
  if (!currentSection) {
1836
- currentSection = {
1837
- id: `default-${planId}`,
1838
- name: "Service",
1839
- presentations: []
1840
- };
1939
+ currentSection = { id: `default-${planId}`, name: "Service", presentations: [] };
1841
1940
  }
1842
1941
  const presentation = await this.convertToPresentation(item, auth);
1843
1942
  if (presentation) {
@@ -1848,12 +1947,7 @@ var PlanningCenterProvider = class extends ContentProvider {
1848
1947
  if (currentSection && currentSection.presentations.length > 0) {
1849
1948
  sections.push(currentSection);
1850
1949
  }
1851
- return {
1852
- id: planId,
1853
- name: folder.title,
1854
- sections,
1855
- allFiles
1856
- };
1950
+ return { id: planId, name: planTitle, sections, allFiles };
1857
1951
  }
1858
1952
  async convertToPresentation(item, auth) {
1859
1953
  const itemType = item.attributes.item_type;
@@ -1864,17 +1958,7 @@ var PlanningCenterProvider = class extends ContentProvider {
1864
1958
  return this.convertMediaToPresentation(item, auth);
1865
1959
  }
1866
1960
  if (itemType === "item") {
1867
- return {
1868
- id: item.id,
1869
- name: item.attributes.title || "",
1870
- actionType: "other",
1871
- files: [],
1872
- providerData: {
1873
- itemType: "item",
1874
- description: item.attributes.description,
1875
- length: item.attributes.length
1876
- }
1877
- };
1961
+ return { id: item.id, name: item.attributes.title || "", actionType: "other", files: [], providerData: { itemType: "item", description: item.attributes.description, length: item.attributes.length } };
1878
1962
  }
1879
1963
  return null;
1880
1964
  }
@@ -1882,13 +1966,7 @@ var PlanningCenterProvider = class extends ContentProvider {
1882
1966
  const songId = item.relationships?.song?.data?.id;
1883
1967
  const arrangementId = item.relationships?.arrangement?.data?.id;
1884
1968
  if (!songId) {
1885
- return {
1886
- id: item.id,
1887
- name: item.attributes.title || "Song",
1888
- actionType: "other",
1889
- files: [],
1890
- providerData: { itemType: "song" }
1891
- };
1969
+ return { id: item.id, name: item.attributes.title || "Song", actionType: "other", files: [], providerData: { itemType: "song" } };
1892
1970
  }
1893
1971
  const songFn = this.config.endpoints.song;
1894
1972
  const songResponse = await this.apiRequest(songFn(songId), auth);
@@ -1910,25 +1988,7 @@ var PlanningCenterProvider = class extends ContentProvider {
1910
1988
  }
1911
1989
  const song = songResponse?.data;
1912
1990
  const title = song?.attributes?.title || item.attributes.title || "Song";
1913
- return {
1914
- id: item.id,
1915
- name: title,
1916
- actionType: "other",
1917
- files: [],
1918
- providerData: {
1919
- itemType: "song",
1920
- title,
1921
- author: song?.attributes?.author,
1922
- copyright: song?.attributes?.copyright,
1923
- ccliNumber: song?.attributes?.ccli_number,
1924
- arrangementName: arrangement?.attributes?.name,
1925
- keySignature: arrangement?.attributes?.chord_chart_key,
1926
- bpm: arrangement?.attributes?.bpm,
1927
- sequence: arrangement?.attributes?.sequence,
1928
- sections: sections.map((s) => ({ label: s.label, lyrics: s.lyrics })),
1929
- length: item.attributes.length
1930
- }
1931
- };
1991
+ return { id: item.id, name: title, actionType: "other", files: [], providerData: { itemType: "song", title, author: song?.attributes?.author, copyright: song?.attributes?.copyright, ccliNumber: song?.attributes?.ccli_number, arrangementName: arrangement?.attributes?.name, keySignature: arrangement?.attributes?.chord_chart_key, bpm: arrangement?.attributes?.bpm, sequence: arrangement?.attributes?.sequence, sections: sections.map((s) => ({ label: s.label, lyrics: s.lyrics })), length: item.attributes.length } };
1932
1992
  }
1933
1993
  async convertMediaToPresentation(item, auth) {
1934
1994
  const files = [];
@@ -1948,25 +2008,10 @@ var PlanningCenterProvider = class extends ContentProvider {
1948
2008
  if (!url) continue;
1949
2009
  const contentType = attachment.attributes.content_type;
1950
2010
  const explicitType = contentType?.startsWith("video/") ? "video" : void 0;
1951
- files.push({
1952
- type: "file",
1953
- id: attachment.id,
1954
- title: attachment.attributes.filename,
1955
- mediaType: detectMediaType(url, explicitType),
1956
- url
1957
- });
2011
+ files.push({ type: "file", id: attachment.id, title: attachment.attributes.filename, mediaType: detectMediaType(url, explicitType), url });
1958
2012
  }
1959
2013
  }
1960
- return {
1961
- id: item.id,
1962
- name: item.attributes.title || "Media",
1963
- actionType: "play",
1964
- files,
1965
- providerData: {
1966
- itemType: "media",
1967
- length: item.attributes.length
1968
- }
1969
- };
2014
+ return { id: item.id, name: item.attributes.title || "Media", actionType: "play", files, providerData: { itemType: "media", length: item.attributes.length } };
1970
2015
  }
1971
2016
  formatDate(dateString) {
1972
2017
  const date = new Date(dateString);
@@ -1974,7 +2019,7 @@ var PlanningCenterProvider = class extends ContentProvider {
1974
2019
  }
1975
2020
  };
1976
2021
 
1977
- // src/providers/bibleproject/data.json
2022
+ // src/providers/bibleProject/data.json
1978
2023
  var data_default = {
1979
2024
  collections: [
1980
2025
  {
@@ -4082,10 +4127,9 @@ var data_default = {
4082
4127
  ]
4083
4128
  };
4084
4129
 
4085
- // src/providers/bibleproject/BibleProjectProvider.ts
4086
- var BibleProjectProvider = class extends ContentProvider {
4130
+ // src/providers/bibleProject/BibleProjectProvider.ts
4131
+ var BibleProjectProvider = class {
4087
4132
  constructor() {
4088
- super(...arguments);
4089
4133
  this.id = "bibleproject";
4090
4134
  this.name = "The Bible Project";
4091
4135
  this.logos = {
@@ -4104,74 +4148,109 @@ var BibleProjectProvider = class extends ContentProvider {
4104
4148
  }
4105
4149
  };
4106
4150
  this.data = data_default;
4107
- }
4108
- requiresAuth() {
4109
- return false;
4110
- }
4111
- getCapabilities() {
4112
- return {
4151
+ this.requiresAuth = false;
4152
+ this.authTypes = ["none"];
4153
+ this.capabilities = {
4113
4154
  browse: true,
4114
- presentations: false,
4115
- playlist: false,
4155
+ presentations: true,
4156
+ playlist: true,
4116
4157
  instructions: false,
4117
4158
  expandedInstructions: false,
4118
4159
  mediaLicensing: false
4119
4160
  };
4120
4161
  }
4121
- async browse(folder, _auth) {
4122
- if (!folder) {
4123
- return this.data.collections.filter((collection) => collection.videos.length > 0).map((collection) => this.createFolder(
4124
- this.slugify(collection.name),
4125
- collection.name,
4126
- collection.image || void 0,
4127
- { level: "collection", collectionName: collection.name }
4128
- ));
4162
+ async browse(path, _auth) {
4163
+ const { segments, depth } = parsePath(path);
4164
+ if (depth === 0) {
4165
+ return this.getCollections();
4129
4166
  }
4130
- const level = folder.providerData?.level;
4131
- const collectionName = folder.providerData?.collectionName;
4132
- if (level === "collection") {
4133
- return this.getLessonFolders(collectionName);
4167
+ if (depth === 1) {
4168
+ const collectionSlug = segments[0];
4169
+ return this.getLessonFolders(collectionSlug, path);
4134
4170
  }
4135
- if (level === "lesson") {
4136
- const videoData = folder.providerData?.videoData;
4137
- if (videoData) {
4138
- return [this.createFile(
4139
- videoData.id,
4140
- videoData.title,
4141
- videoData.videoUrl,
4142
- {
4143
- mediaType: "video",
4144
- muxPlaybackId: videoData.muxPlaybackId
4145
- }
4146
- )];
4147
- }
4148
- return [];
4171
+ if (depth === 2) {
4172
+ const collectionSlug = segments[0];
4173
+ const videoId = segments[1];
4174
+ return this.getVideoFile(collectionSlug, videoId);
4149
4175
  }
4150
4176
  return [];
4151
4177
  }
4152
- async getPresentations(_folder, _auth) {
4153
- return null;
4178
+ getCollections() {
4179
+ return this.data.collections.filter((collection) => collection.videos.length > 0).map((collection) => ({
4180
+ type: "folder",
4181
+ id: this.slugify(collection.name),
4182
+ title: collection.name,
4183
+ image: collection.image || void 0,
4184
+ path: `/${this.slugify(collection.name)}`
4185
+ }));
4154
4186
  }
4155
- getLessonFolders(collectionName) {
4156
- const collection = this.data.collections.find((c) => c.name === collectionName);
4187
+ getLessonFolders(collectionSlug, currentPath) {
4188
+ const collection = this.data.collections.find((c) => this.slugify(c.name) === collectionSlug);
4157
4189
  if (!collection) return [];
4158
- return collection.videos.map((video) => this.createFolder(
4159
- video.id,
4160
- video.title,
4161
- video.thumbnailUrl,
4162
- {
4163
- level: "lesson",
4164
- collectionName,
4165
- videoData: video
4166
- }
4167
- ));
4190
+ return collection.videos.map((video) => ({
4191
+ type: "folder",
4192
+ id: video.id,
4193
+ title: video.title,
4194
+ image: video.thumbnailUrl,
4195
+ isLeaf: true,
4196
+ path: `${currentPath}/${video.id}`,
4197
+ providerData: { videoData: video }
4198
+ }));
4199
+ }
4200
+ getVideoFile(collectionSlug, videoId) {
4201
+ const collection = this.data.collections.find((c) => this.slugify(c.name) === collectionSlug);
4202
+ if (!collection) return [];
4203
+ const video = collection.videos.find((v) => v.id === videoId);
4204
+ if (!video) return [];
4205
+ return [createFile(video.id, video.title, video.videoUrl, { mediaType: "video", muxPlaybackId: video.muxPlaybackId })];
4206
+ }
4207
+ async getPresentations(path, _auth) {
4208
+ const { segments, depth } = parsePath(path);
4209
+ if (depth < 1) return null;
4210
+ const collectionSlug = segments[0];
4211
+ const collection = this.data.collections.find((c) => this.slugify(c.name) === collectionSlug);
4212
+ if (!collection) return null;
4213
+ if (depth === 1) {
4214
+ const allFiles = [];
4215
+ const presentations = collection.videos.map((video) => {
4216
+ const file = { type: "file", id: video.id, title: video.title, mediaType: "video", url: video.videoUrl, image: video.thumbnailUrl, muxPlaybackId: video.muxPlaybackId };
4217
+ allFiles.push(file);
4218
+ return { id: video.id, name: video.title, actionType: "play", files: [file] };
4219
+ });
4220
+ return { id: this.slugify(collection.name), name: collection.name, image: collection.image || void 0, sections: [{ id: "videos", name: "Videos", presentations }], allFiles };
4221
+ }
4222
+ if (depth === 2) {
4223
+ const videoId = segments[1];
4224
+ const video = collection.videos.find((v) => v.id === videoId);
4225
+ if (!video) return null;
4226
+ const file = { type: "file", id: video.id, title: video.title, mediaType: "video", url: video.videoUrl, image: video.thumbnailUrl, muxPlaybackId: video.muxPlaybackId };
4227
+ return { id: video.id, name: video.title, image: video.thumbnailUrl, sections: [{ id: "main", name: "Content", presentations: [{ id: video.id, name: video.title, actionType: "play", files: [file] }] }], allFiles: [file] };
4228
+ }
4229
+ return null;
4230
+ }
4231
+ async getPlaylist(path, _auth, _resolution) {
4232
+ const { segments, depth } = parsePath(path);
4233
+ if (depth < 1) return null;
4234
+ const collectionSlug = segments[0];
4235
+ const collection = this.data.collections.find((c) => this.slugify(c.name) === collectionSlug);
4236
+ if (!collection) return null;
4237
+ if (depth === 1) {
4238
+ return collection.videos.map((video) => ({ type: "file", id: video.id, title: video.title, mediaType: "video", url: video.videoUrl, image: video.thumbnailUrl, muxPlaybackId: video.muxPlaybackId }));
4239
+ }
4240
+ if (depth === 2) {
4241
+ const videoId = segments[1];
4242
+ const video = collection.videos.find((v) => v.id === videoId);
4243
+ if (!video) return null;
4244
+ return [{ type: "file", id: video.id, title: video.title, mediaType: "video", url: video.videoUrl, image: video.thumbnailUrl, muxPlaybackId: video.muxPlaybackId }];
4245
+ }
4246
+ return null;
4168
4247
  }
4169
4248
  slugify(text) {
4170
4249
  return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
4171
4250
  }
4172
4251
  };
4173
4252
 
4174
- // src/providers/highvoltage/data.json
4253
+ // src/providers/highVoltage/data.json
4175
4254
  var data_default2 = {
4176
4255
  collections: [
4177
4256
  {
@@ -11675,10 +11754,9 @@ var data_default2 = {
11675
11754
  ]
11676
11755
  };
11677
11756
 
11678
- // src/providers/HighVoltageKidsProvider.ts
11679
- var HighVoltageKidsProvider = class extends ContentProvider {
11757
+ // src/providers/highVoltage/HighVoltageKidsProvider.ts
11758
+ var HighVoltageKidsProvider = class {
11680
11759
  constructor() {
11681
- super(...arguments);
11682
11760
  this.id = "highvoltagekids";
11683
11761
  this.name = "High Voltage Kids";
11684
11762
  this.logos = {
@@ -11697,87 +11775,211 @@ var HighVoltageKidsProvider = class extends ContentProvider {
11697
11775
  }
11698
11776
  };
11699
11777
  this.data = data_default2;
11700
- }
11701
- requiresAuth() {
11702
- return false;
11703
- }
11704
- getCapabilities() {
11705
- return {
11778
+ this.requiresAuth = false;
11779
+ this.authTypes = ["none"];
11780
+ this.capabilities = {
11706
11781
  browse: true,
11707
- presentations: false,
11708
- playlist: false,
11782
+ presentations: true,
11783
+ playlist: true,
11709
11784
  instructions: false,
11710
- expandedInstructions: false,
11785
+ expandedInstructions: true,
11711
11786
  mediaLicensing: false
11712
11787
  };
11713
11788
  }
11714
- async browse(folder, _auth) {
11715
- if (!folder) {
11716
- return this.data.collections.filter((collection) => collection.folders.length > 0).map((collection) => this.createFolder(
11717
- this.slugify(collection.name),
11718
- collection.name,
11719
- void 0,
11720
- { level: "collection", collectionName: collection.name }
11721
- ));
11789
+ async browse(path, _auth) {
11790
+ const { segments, depth } = parsePath(path);
11791
+ if (depth === 0) {
11792
+ return this.getCollections();
11722
11793
  }
11723
- const level = folder.providerData?.level;
11724
- const collectionName = folder.providerData?.collectionName;
11725
- if (level === "collection") {
11726
- return this.getStudyFolders(collectionName);
11794
+ if (depth === 1) {
11795
+ const collectionSlug = segments[0];
11796
+ return this.getStudyFolders(collectionSlug, path);
11727
11797
  }
11728
- if (level === "study") {
11729
- const studyData = folder.providerData?.studyData;
11730
- if (studyData) {
11731
- return this.getLessonFolders(studyData);
11732
- }
11733
- return [];
11798
+ if (depth === 2) {
11799
+ const collectionSlug = segments[0];
11800
+ const studyId = segments[1];
11801
+ return this.getLessonFolders(collectionSlug, studyId, path);
11734
11802
  }
11735
- if (level === "lesson") {
11736
- const lessonData = folder.providerData?.lessonData;
11737
- if (lessonData?.files) {
11738
- return lessonData.files.map((file) => this.createFile(
11739
- file.id,
11740
- file.title,
11741
- file.url,
11742
- { mediaType: file.mediaType }
11743
- ));
11744
- }
11745
- return [];
11803
+ if (depth === 3) {
11804
+ const collectionSlug = segments[0];
11805
+ const studyId = segments[1];
11806
+ const lessonId = segments[2];
11807
+ return this.getLessonFiles(collectionSlug, studyId, lessonId);
11746
11808
  }
11747
11809
  return [];
11748
11810
  }
11749
- async getPresentations(_folder, _auth) {
11750
- return null;
11811
+ getCollections() {
11812
+ return this.data.collections.filter((collection) => collection.folders.length > 0).map((collection) => ({
11813
+ type: "folder",
11814
+ id: this.slugify(collection.name),
11815
+ title: collection.name,
11816
+ path: `/${this.slugify(collection.name)}`
11817
+ }));
11751
11818
  }
11752
- getStudyFolders(collectionName) {
11753
- const collection = this.data.collections.find((c) => c.name === collectionName);
11819
+ getStudyFolders(collectionSlug, currentPath) {
11820
+ const collection = this.data.collections.find((c) => this.slugify(c.name) === collectionSlug);
11754
11821
  if (!collection) return [];
11755
- return collection.folders.map((study) => this.createFolder(
11756
- study.id,
11757
- study.name,
11758
- study.image || void 0,
11759
- {
11760
- level: "study",
11761
- collectionName,
11762
- studyData: study
11763
- }
11764
- ));
11765
- }
11766
- getLessonFolders(study) {
11767
- return study.lessons.map((lesson) => this.createFolder(
11768
- lesson.id,
11769
- lesson.name,
11770
- lesson.image || void 0,
11771
- {
11772
- level: "lesson",
11773
- studyId: study.id,
11774
- lessonData: lesson
11822
+ return collection.folders.map((study) => ({
11823
+ type: "folder",
11824
+ id: study.id,
11825
+ title: study.name,
11826
+ image: study.image || void 0,
11827
+ path: `${currentPath}/${study.id}`,
11828
+ providerData: { studyData: study }
11829
+ }));
11830
+ }
11831
+ getLessonFolders(collectionSlug, studyId, currentPath) {
11832
+ const collection = this.data.collections.find((c) => this.slugify(c.name) === collectionSlug);
11833
+ if (!collection) return [];
11834
+ const study = collection.folders.find((s) => s.id === studyId);
11835
+ if (!study) return [];
11836
+ return study.lessons.map((lesson) => ({
11837
+ type: "folder",
11838
+ id: lesson.id,
11839
+ title: lesson.name,
11840
+ image: lesson.image || void 0,
11841
+ isLeaf: true,
11842
+ path: `${currentPath}/${lesson.id}`,
11843
+ providerData: { lessonData: lesson, studyName: study.name }
11844
+ }));
11845
+ }
11846
+ getLessonFiles(collectionSlug, studyId, lessonId) {
11847
+ const collection = this.data.collections.find((c) => this.slugify(c.name) === collectionSlug);
11848
+ if (!collection) return [];
11849
+ const study = collection.folders.find((s) => s.id === studyId);
11850
+ if (!study) return [];
11851
+ const lesson = study.lessons.find((l) => l.id === lessonId);
11852
+ if (!lesson?.files) return [];
11853
+ return lesson.files.map((file) => createFile(file.id, file.title, file.url, { mediaType: file.mediaType }));
11854
+ }
11855
+ async getPresentations(path, _auth) {
11856
+ const { segments, depth } = parsePath(path);
11857
+ if (depth < 2) return null;
11858
+ const collectionSlug = segments[0];
11859
+ const studyId = segments[1];
11860
+ const collection = this.data.collections.find((c) => this.slugify(c.name) === collectionSlug);
11861
+ if (!collection) return null;
11862
+ const study = collection.folders.find((s) => s.id === studyId);
11863
+ if (!study) return null;
11864
+ if (depth === 2) {
11865
+ const allFiles = [];
11866
+ const sections = study.lessons.map((lesson) => {
11867
+ const files = lesson.files.map((file) => {
11868
+ const contentFile = { type: "file", id: file.id, title: file.title, mediaType: file.mediaType, url: file.url, image: lesson.image };
11869
+ allFiles.push(contentFile);
11870
+ return contentFile;
11871
+ });
11872
+ const presentation = { id: lesson.id, name: lesson.name, actionType: "play", files };
11873
+ return { id: lesson.id, name: lesson.name, presentations: [presentation] };
11874
+ });
11875
+ return { id: study.id, name: study.name, description: study.description, image: study.image, sections, allFiles };
11876
+ }
11877
+ if (depth === 3) {
11878
+ const lessonId = segments[2];
11879
+ const lesson = study.lessons.find((l) => l.id === lessonId);
11880
+ if (!lesson?.files) return null;
11881
+ const files = lesson.files.map((file) => ({ type: "file", id: file.id, title: file.title, mediaType: file.mediaType, url: file.url, image: lesson.image }));
11882
+ const presentation = { id: lesson.id, name: lesson.name, actionType: "play", files };
11883
+ return { id: lesson.id, name: lesson.name, image: lesson.image, sections: [{ id: "main", name: "Content", presentations: [presentation] }], allFiles: files };
11884
+ }
11885
+ return null;
11886
+ }
11887
+ async getPlaylist(path, _auth, _resolution) {
11888
+ const { segments, depth } = parsePath(path);
11889
+ if (depth < 2) return null;
11890
+ const collectionSlug = segments[0];
11891
+ const studyId = segments[1];
11892
+ const collection = this.data.collections.find((c) => this.slugify(c.name) === collectionSlug);
11893
+ if (!collection) return null;
11894
+ const study = collection.folders.find((s) => s.id === studyId);
11895
+ if (!study) return null;
11896
+ if (depth === 2) {
11897
+ const allFiles = [];
11898
+ for (const lesson of study.lessons) {
11899
+ for (const file of lesson.files) {
11900
+ allFiles.push({ type: "file", id: file.id, title: file.title, mediaType: file.mediaType, url: file.url, image: lesson.image });
11901
+ }
11775
11902
  }
11776
- ));
11903
+ return allFiles;
11904
+ }
11905
+ if (depth === 3) {
11906
+ const lessonId = segments[2];
11907
+ const lesson = study.lessons.find((l) => l.id === lessonId);
11908
+ if (!lesson?.files) return null;
11909
+ return lesson.files.map((file) => ({ type: "file", id: file.id, title: file.title, mediaType: file.mediaType, url: file.url, image: lesson.image }));
11910
+ }
11911
+ return null;
11912
+ }
11913
+ async getExpandedInstructions(path, _auth) {
11914
+ const { segments, depth } = parsePath(path);
11915
+ if (depth < 2) return null;
11916
+ const collectionSlug = segments[0];
11917
+ const studyId = segments[1];
11918
+ const collection = this.data.collections.find((c) => this.slugify(c.name) === collectionSlug);
11919
+ if (!collection) return null;
11920
+ const study = collection.folders.find((s) => s.id === studyId);
11921
+ if (!study) return null;
11922
+ if (depth === 2) {
11923
+ const lessonItems = study.lessons.map((lesson) => {
11924
+ const fileItems = lesson.files.map((file) => ({ id: file.id, itemType: "file", label: file.title, embedUrl: file.url }));
11925
+ return { id: lesson.id, itemType: "action", label: lesson.name, description: "play", children: fileItems };
11926
+ });
11927
+ return { venueName: study.name, items: [{ id: study.id, itemType: "header", label: study.name, children: [{ id: "main", itemType: "section", label: "Content", children: lessonItems }] }] };
11928
+ }
11929
+ if (depth === 3) {
11930
+ const lessonId = segments[2];
11931
+ const lesson = study.lessons.find((l) => l.id === lessonId);
11932
+ if (!lesson?.files) return null;
11933
+ const headerLabel = `${study.name} - ${lesson.name}`;
11934
+ const actionItems = this.groupFilesIntoActions(lesson.files);
11935
+ return { venueName: lesson.name, items: [{ id: lesson.id, itemType: "header", label: headerLabel, children: [{ id: "main", itemType: "section", label: lesson.name, children: actionItems }] }] };
11936
+ }
11937
+ return null;
11777
11938
  }
11778
11939
  slugify(text) {
11779
11940
  return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
11780
11941
  }
11942
+ groupFilesIntoActions(files) {
11943
+ const actionItems = [];
11944
+ let currentGroup = [];
11945
+ let currentBaseName = null;
11946
+ const flushGroup = () => {
11947
+ if (currentGroup.length === 0) return;
11948
+ const children = currentGroup.map((file) => ({
11949
+ id: file.id,
11950
+ itemType: "file",
11951
+ label: file.title,
11952
+ embedUrl: file.url
11953
+ }));
11954
+ const label = currentGroup.length > 1 && currentBaseName ? currentBaseName : currentGroup[0].title;
11955
+ actionItems.push({
11956
+ id: currentGroup[0].id + "-action",
11957
+ itemType: "action",
11958
+ label,
11959
+ description: "play",
11960
+ children
11961
+ });
11962
+ currentGroup = [];
11963
+ currentBaseName = null;
11964
+ };
11965
+ for (const file of files) {
11966
+ const baseName = this.getBaseName(file.title);
11967
+ const isNumbered = baseName !== file.title;
11968
+ if (isNumbered && baseName === currentBaseName) {
11969
+ currentGroup.push(file);
11970
+ } else {
11971
+ flushGroup();
11972
+ currentGroup = [file];
11973
+ currentBaseName = isNumbered ? baseName : null;
11974
+ }
11975
+ }
11976
+ flushGroup();
11977
+ return actionItems;
11978
+ }
11979
+ getBaseName(title) {
11980
+ const match = title.match(/^(.+?)\s*\d+$/);
11981
+ return match ? match[1].trim() : title;
11982
+ }
11781
11983
  };
11782
11984
 
11783
11985
  // src/providers/index.ts
@@ -11862,15 +12064,15 @@ function getProviderConfig(providerId) {
11862
12064
  const provider = getProvider(providerId);
11863
12065
  return provider?.config || null;
11864
12066
  }
11865
- function getAvailableProviders() {
12067
+ function getAvailableProviders(ids) {
11866
12068
  const implemented = getAllProviders().map((provider) => ({
11867
12069
  id: provider.id,
11868
12070
  name: provider.name,
11869
12071
  logos: provider.logos,
11870
12072
  implemented: true,
11871
- requiresAuth: provider.requiresAuth(),
11872
- authTypes: provider.getAuthTypes(),
11873
- capabilities: provider.getCapabilities()
12073
+ requiresAuth: provider.requiresAuth,
12074
+ authTypes: provider.authTypes,
12075
+ capabilities: provider.capabilities
11874
12076
  }));
11875
12077
  const comingSoon = unimplementedProviders.map((p) => ({
11876
12078
  id: p.id,
@@ -11881,7 +12083,12 @@ function getAvailableProviders() {
11881
12083
  authTypes: [],
11882
12084
  capabilities: { browse: false, presentations: false, playlist: false, instructions: false, expandedInstructions: false, mediaLicensing: false }
11883
12085
  }));
11884
- return [...implemented, ...comingSoon];
12086
+ const all = [...implemented, ...comingSoon];
12087
+ if (ids && ids.length > 0) {
12088
+ const idSet = new Set(ids);
12089
+ return all.filter((provider) => idSet.has(provider.id));
12090
+ }
12091
+ return all;
11885
12092
  }
11886
12093
 
11887
12094
  // src/index.ts
@@ -11889,20 +12096,44 @@ var VERSION = "0.0.1";
11889
12096
  // Annotate the CommonJS export names for ESM import in node:
11890
12097
  0 && (module.exports = {
11891
12098
  APlayProvider,
12099
+ ApiHelper,
11892
12100
  B1ChurchProvider,
11893
12101
  BibleProjectProvider,
11894
12102
  ContentProvider,
12103
+ DeviceFlowHelper,
12104
+ FormatConverters,
12105
+ FormatResolver,
12106
+ HighVoltageKidsProvider,
11895
12107
  LessonsChurchProvider,
12108
+ OAuthHelper,
11896
12109
  PlanningCenterProvider,
11897
12110
  SignPresenterProvider,
12111
+ TokenHelper,
11898
12112
  VERSION,
12113
+ appendToPath,
12114
+ buildPath,
12115
+ collapseInstructions,
12116
+ createFile,
12117
+ createFolder,
11899
12118
  detectMediaType,
12119
+ expandedInstructionsToPlaylist,
12120
+ expandedInstructionsToPresentations,
11900
12121
  getAllProviders,
11901
12122
  getAvailableProviders,
11902
12123
  getProvider,
11903
12124
  getProviderConfig,
12125
+ getSegment,
12126
+ instructionsToPlaylist,
12127
+ instructionsToPresentations,
11904
12128
  isContentFile,
11905
12129
  isContentFolder,
12130
+ parsePath,
12131
+ playlistToExpandedInstructions,
12132
+ playlistToInstructions,
12133
+ playlistToPresentations,
12134
+ presentationsToExpandedInstructions,
12135
+ presentationsToInstructions,
12136
+ presentationsToPlaylist,
11906
12137
  registerProvider
11907
12138
  });
11908
12139
  //# sourceMappingURL=index.cjs.map