musora-content-services 2.7.1 → 2.7.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/docs/ContentOrganization.html +2 -2
  3. package/docs/Gamification.html +2 -2
  4. package/docs/UserManagementSystem.html +2 -2
  5. package/docs/api_types.js.html +2 -2
  6. package/docs/config.js.html +2 -2
  7. package/docs/content-org_content-org.js.html +2 -2
  8. package/docs/content-org_playlists-types.js.html +3 -5
  9. package/docs/content-org_playlists.js.html +23 -9
  10. package/docs/content.js.html +9 -2
  11. package/docs/gamification_awards.js.html +2 -2
  12. package/docs/gamification_gamification.js.html +2 -2
  13. package/docs/gamification_types.js.html +2 -2
  14. package/docs/global.html +5 -53
  15. package/docs/index.html +2 -2
  16. package/docs/module-Awards.html +2 -2
  17. package/docs/module-Config.html +2 -2
  18. package/docs/module-Content-Services-V2.html +8 -8
  19. package/docs/module-Interests.html +2 -2
  20. package/docs/module-Permissions.html +2 -2
  21. package/docs/module-Playlists.html +21 -20
  22. package/docs/module-Railcontent-Services.html +2 -2
  23. package/docs/module-Sanity-Services.html +30 -30
  24. package/docs/module-Sessions.html +2 -2
  25. package/docs/module-User-Activity.html +737 -58
  26. package/docs/module-UserManagement.html +2 -2
  27. package/docs/module-UserProfile.html +2 -2
  28. package/docs/railcontent.js.html +2 -2
  29. package/docs/sanity.js.html +24 -6
  30. package/docs/userActivity.js.html +360 -12
  31. package/docs/user_interests.js.html +2 -2
  32. package/docs/user_management.js.html +2 -2
  33. package/docs/user_permissions.js.html +2 -2
  34. package/docs/user_profile.js.html +4 -4
  35. package/docs/user_sessions.js.html +2 -2
  36. package/docs/user_types.js.html +2 -2
  37. package/docs/user_user-management-system.js.html +2 -2
  38. package/link_mcs.sh +8 -6
  39. package/package.json +1 -1
  40. package/src/contentMetaData.js +2 -1
  41. package/src/contentTypeConfig.js +19 -3
  42. package/src/index.d.ts +10 -2
  43. package/src/index.js +10 -2
  44. package/src/services/content.js +7 -0
  45. package/src/services/contentProgress.js +45 -15
  46. package/src/services/dateUtils.js +0 -0
  47. package/src/services/sanity.js +22 -4
  48. package/src/services/userActivity.js +348 -10
  49. package/test/mockData/mockData_progress_content.json +182 -0
  50. package/test/mockData/mockData_sanity_progress_content.json +1451 -0
  51. package/test/progressRows.test.js +215 -0
  52. package/test/sanityQueryService.test.js +0 -0
  53. package/test/userActivity.test.js +4 -4
@@ -512,15 +512,18 @@ export async function fetchByRailContentId(id, contentType) {
512
512
  * .then(contents => console.log(contents))
513
513
  * .catch(error => console.error(error));
514
514
  */
515
- export async function fetchByRailContentIds(ids, contentType = undefined) {
515
+ export async function fetchByRailContentIds(ids, contentType = undefined, brand= undefined) {
516
516
  if (!ids) {
517
517
  return []
518
518
  }
519
519
  const idsString = ids.join(',')
520
+ const brandFilter = brand ? ` && brand == "${brand}"` : ''
521
+ const query = `*[
522
+ railcontent_id in [${idsString}]${brandFilter}
523
+ ]{
524
+ ${getFieldsForContentType(contentType)}
525
+ }`
520
526
 
521
- const query = `*[railcontent_id in [${idsString}]]{
522
- ${getFieldsForContentType(contentType)}
523
- }`
524
527
  const results = await fetchSanity(query, true)
525
528
 
526
529
  const sortFuction = function compare(a, b) {
@@ -2355,3 +2358,18 @@ export async function fetchScheduledAndNewReleases(
2355
2358
 
2356
2359
  return fetchSanity(query, true)
2357
2360
  }
2361
+
2362
+ export async function fetchShows(
2363
+ brand,
2364
+ type,
2365
+ sort = 'sort'
2366
+ ) {
2367
+ const sortOrder = getSortOrder(sort, brand)
2368
+ const filter = `_type == '${type}' && brand == '${brand}'`
2369
+ const filterParams = {}
2370
+
2371
+ const query = await buildQuery(filter, filterParams, getFieldsForContentType(type), {
2372
+ sortOrder: sortOrder
2373
+ })
2374
+ return fetchSanity(query, true)
2375
+ }
@@ -11,10 +11,13 @@ import {
11
11
  fetchRecentUserActivities,
12
12
  } from './railcontent'
13
13
  import { DataContext, UserActivityVersionKey } from './dataContext.js'
14
- import { fetchByRailContentIds } from './sanity'
15
- import { lessonTypesMapping } from '../contentTypeConfig'
14
+ import { fetchByRailContentIds, fetchShows } from './sanity'
15
+ import {fetchUserPlaylists} from "./content-org/playlists";
16
16
  import { convertToTimeZone, getMonday, getWeekNumber, isSameDate, isNextDay } from './dateUtils.js'
17
17
  import { globalConfig } from './config'
18
+ import {collectionLessonTypes, lessonTypesMapping, progressTypesMapping} from "../contentTypeConfig";
19
+ import {getAllStartedOrCompleted, getProgressStateByIds} from "./contentProgress";
20
+ import {TabResponseType} from "../contentMetaData";
18
21
 
19
22
  const DATA_KEY_PRACTICES = 'practices'
20
23
  const DATA_KEY_LAST_UPDATED_TIME = 'u'
@@ -835,14 +838,6 @@ async function formatPracticeMeta(practices) {
835
838
  })
836
839
  }
837
840
 
838
- export function getFormattedType(type) {
839
- for (const [key, values] of Object.entries(lessonTypesMapping)) {
840
- if (values.includes(type)) {
841
- return key.replace(/\b\w/g, (char) => char.toUpperCase())
842
- }
843
- }
844
- return null
845
- }
846
841
 
847
842
  /**
848
843
  * Records a new user activity in the system.
@@ -887,3 +882,346 @@ export async function deleteUserActivity(id) {
887
882
  const url = `/api/user-management-system/v1/activities/${id}`
888
883
  return await fetchHandler(url, 'DELETE')
889
884
  }
885
+ /**
886
+ * Fetches and combines recent user progress rows and playlists, excluding certain types and parents.
887
+ *
888
+ * @param {Object} [options={}] - Options for fetching progress rows.
889
+ * @param {string|null} [options.brand=null] - The brand context for progress data.
890
+ * @param {number} [options.limit=8] - Maximum number of progress rows to return.
891
+ * @returns {Promise<Object>} - A promise that resolves to an object containing progress rows formatted for UI.
892
+ *
893
+ * @example
894
+ * getProgressRows({ brand: 'drumeo', limit: 10 })
895
+ * .then(data => console.log(data))
896
+ * .catch(error => console.error(error));
897
+ */
898
+ export async function getProgressRows({ brand = null, limit = 8 } = {}) {
899
+ const excludedTypes = new Set([
900
+ 'pack-bundle',
901
+ 'learning-path-course',
902
+ 'learning-path-level'
903
+ ]);
904
+
905
+ const recentPlaylists = await fetchUserPlaylists(brand, {
906
+ sort: '-last_progress',
907
+ limit: limit,
908
+ });
909
+ const playlists = recentPlaylists?.data || [];
910
+ const eligiblePlaylistItems = await getEligiblePlaylistItems(playlists);
911
+ const playlistEngagedOnContents = eligiblePlaylistItems.map(item => item.last_engaged_on);
912
+ const playlistsContents = await fetchByRailContentIds(playlistEngagedOnContents, 'progress-tracker');
913
+ const excludedParents = new Set();
914
+ for (const item of playlistsContents) {
915
+ const contentId = item.id ?? item.railcontent_id;
916
+ excludedParents.add(contentId)
917
+ const parentIds = item.parent_content_data || [];
918
+ parentIds.forEach(id => excludedParents.add(id));
919
+ }
920
+
921
+ const progressContents = await getAllStartedOrCompleted({onlyIds: false, brand: brand, excludedIds: Array.from(excludedParents) });
922
+ const contents = await fetchByRailContentIds(Object.keys(progressContents), 'progress-tracker', brand);
923
+ const contentsMap = {};
924
+ contents.forEach(content => {
925
+ contentsMap[content.railcontent_id] = content;
926
+ });
927
+ const childToParentMap = {};
928
+ Object.values(contentsMap).forEach(content => {
929
+ if (Array.isArray(content.parent_content_data) && content.parent_content_data.length > 0) {
930
+ childToParentMap[content.id] = content.parent_content_data[content.parent_content_data.length - 1];
931
+ }
932
+ });
933
+ const progressMap = new Map();
934
+ for (const [idStr, progress] of Object.entries(progressContents)) {
935
+ const id = parseInt(idStr);
936
+ const content = contentsMap[id];
937
+ if (!content || excludedTypes.has(content.type)) continue;
938
+ const parentId = childToParentMap[id];
939
+ // Handle children with parents
940
+ if (parentId) {
941
+ const parentContent = contentsMap[parentId];
942
+ if (!parentContent || excludedTypes.has(parentContent.type)) continue;
943
+ const existing = progressMap.get(parentId);
944
+ if (existing) {
945
+ // If childIndex isn't already set, set it
946
+ if (existing.childIndex === undefined) {
947
+ existing.childIndex = id;
948
+ }
949
+ } else {
950
+ progressMap.set(parentId, {
951
+ id: parentId,
952
+ raw: parentContent,
953
+ state: progress.status,
954
+ percent: progress.progress,
955
+ progressTimestamp: progress.last_update * 1000,
956
+ childIndex: id
957
+ });
958
+ }
959
+ continue;
960
+ }
961
+ // Handle standalone parents
962
+ if (!progressMap.has(id)) {
963
+ progressMap.set(id, {
964
+ id,
965
+ raw: content,
966
+ state: progress.status,
967
+ percent: progress.progress,
968
+ progressTimestamp: progress.last_update * 1000
969
+ });
970
+ }
971
+ }
972
+ const progressList = Array.from(progressMap.values());
973
+
974
+ const combined = mergeAndSortItems([...progressList, ...eligiblePlaylistItems], limit);
975
+ const results = await Promise.all(
976
+ combined.slice(0, limit).map(item =>
977
+ item.type === 'playlist'
978
+ ? processPlaylistItem(item)
979
+ : processContentItem(item)
980
+ )
981
+ );
982
+
983
+ return {
984
+ type: TabResponseType.PROGRESS_ROWS,
985
+ data: results
986
+ };
987
+ }
988
+
989
+ async function processContentItem(item) {
990
+ let data = item.raw;
991
+ const contentType = getFormattedType(data.type);
992
+ const status = item.state;
993
+
994
+ let ctaText = 'Continue';
995
+ if (contentType === 'transcription' || contentType === 'play-along' || contentType === 'jam-track') ctaText = 'Replay Song';
996
+ if (contentType === 'lesson') ctaText = status === 'completed' ? 'Revisit Lesson' : 'Continue';
997
+ if ((contentType === 'pack' || contentType === 'song tutorial' || collectionLessonTypes.includes(contentType)) && status === 'completed') {
998
+ ctaText = 'View Lessons';
999
+ }
1000
+
1001
+ if (data.lesson_count > 0) {
1002
+ if (item.childIndex) {
1003
+ let nextId = item.childIndex
1004
+ const lessonIds = extractLessonIds(item);
1005
+ const progressOnItems = await getProgressStateByIds(lessonIds);
1006
+ const completedCount = Object.values(progressOnItems).filter(value => value === 'completed').length;
1007
+ const nextByProgress = findIncompleteLesson(progressOnItems, item.childIndex, item.raw.type);
1008
+ nextId = nextByProgress ? nextByProgress : nextId;
1009
+
1010
+ const nestedLessons = data.lessons
1011
+ .filter(item => Array.isArray(item.lessons))
1012
+ .flatMap(parent =>
1013
+ parent.lessons.map(lesson => ({
1014
+ ...lesson,
1015
+ parent: {
1016
+ id: parent.id,
1017
+ slug: parent.slug,
1018
+ title: parent.title,
1019
+ type: parent.type
1020
+ }
1021
+ }))
1022
+ );
1023
+
1024
+ const lessons = (nestedLessons.length === 0) ? data.lessons : nestedLessons;
1025
+ const nextLesson = lessons.find(lesson => lesson.id === nextId);
1026
+ data.first_incomplete_child = nextLesson?.parent ?? nextLesson;
1027
+ data.second_incomplete_child = (nextLesson?.parent) ? nextLesson : null;
1028
+ data.completed_children = completedCount;
1029
+ }
1030
+ }
1031
+
1032
+ if(contentType == 'show' && status == 'completed'){
1033
+ const shows = await fetchShows(data.brand, data.type)
1034
+ const showIds = shows.map(item => item.id);
1035
+ const progressOnItems = await getProgressStateByIds(showIds);
1036
+ const nextByProgress = findIncompleteLesson(progressOnItems, data.id, data.type);
1037
+ data = shows.find(lesson => lesson.id === nextByProgress);
1038
+ }
1039
+
1040
+ return {
1041
+ id: item.id,
1042
+ progressType: 'content',
1043
+ header: contentType,
1044
+ body: {
1045
+ progressPercent: item.percent,
1046
+ thumbnail: data.thumbnail,
1047
+ title: data.title,
1048
+ subtitle: !data.child_count || data.lesson_count === 1
1049
+ ? `${data.difficulty_string} • ${data.artist_name}`
1050
+ : `${data.completed_children} of ${data.lesson_count ?? data.child_count} Lessons Complete`
1051
+ },
1052
+ cta: {
1053
+ text: ctaText,
1054
+ action: {
1055
+ type: data.type,
1056
+ brand: data.brand,
1057
+ id: data.id,
1058
+ slug: data.slug,
1059
+ child: data.first_incomplete_child
1060
+ ? {
1061
+ id: data.first_incomplete_child.id,
1062
+ type: data.first_incomplete_child.type,
1063
+ brand: data.first_incomplete_child.brand,
1064
+ slug: data.first_incomplete_child.slug,
1065
+ child: data.second_incomplete_child
1066
+ ? {
1067
+ id: data.second_incomplete_child.id,
1068
+ type: data.second_incomplete_child.type,
1069
+ brand: data.second_incomplete_child.brand,
1070
+ slug: data.second_incomplete_child.slug
1071
+ }
1072
+ : null
1073
+ }
1074
+ : null
1075
+ }
1076
+ },
1077
+ progressTimestamp: item.progressTimestamp
1078
+ };
1079
+ }
1080
+
1081
+ async function processPlaylistItem(item) {
1082
+ const playlist = item.raw;
1083
+ const progressOnItems = await getProgressStateByIds(playlist.items.map(a => a.content_id));
1084
+ const allItemsCompleted = item.raw.items.every(i => {
1085
+ const itemId = i.content_id;
1086
+ const progress = progressOnItems[itemId];
1087
+ return progress && progress === 'completed';
1088
+ });
1089
+ let nextItem = playlist.items[0] ?? null;
1090
+ if (!allItemsCompleted) {
1091
+ const lastItemProgress = progressOnItems[playlist.last_engaged_on];
1092
+ const index = playlist.items.findIndex(i => i.content_id === playlist.last_engaged_on);
1093
+ if (lastItemProgress === 'completed') {
1094
+ nextItem = playlist.items[index + 1] ?? nextItem;
1095
+ } else {
1096
+ nextItem = playlist.items[index] ?? nextItem;
1097
+ }
1098
+ }
1099
+
1100
+ return {
1101
+ id: playlist.id,
1102
+ progressType: 'playlist',
1103
+ header: 'playlist',
1104
+ body: {
1105
+ first_items_thumbnail_url: playlist.first_items_thumbnail_url,
1106
+ title: playlist.name,
1107
+ subtitle: `${playlist.duration_formated} • ${playlist.total_items} items • ${playlist.likes} likes • ${playlist.user.display_name}`
1108
+ },
1109
+ progressTimestamp: item.progressTimestamp,
1110
+ cta: {
1111
+ text: 'Continue',
1112
+ action: {
1113
+ brand: playlist.brand,
1114
+ id: playlist.id,
1115
+ itemId: nextItem.id,
1116
+ type: 'playlists',
1117
+ }
1118
+ }
1119
+ }
1120
+ }
1121
+
1122
+ const getFormattedType = type => {
1123
+ for (const [key, values] of Object.entries(progressTypesMapping)) {
1124
+ if (values.includes(type)) {
1125
+ return key;
1126
+ }
1127
+ }
1128
+ return null;
1129
+ };
1130
+
1131
+ function extractLessonIds(data) {
1132
+ const ids = [];
1133
+ function traverse(lessons) {
1134
+ for (const item of lessons) {
1135
+ if (item.id) {
1136
+ ids.push(item.id);
1137
+ }
1138
+ if (item.lessons) {
1139
+ traverse(item.lessons); // Recursively handle nested lessons
1140
+ }
1141
+ }
1142
+ }
1143
+ if (data.raw && Array.isArray(data.raw.lessons)) {
1144
+ traverse(data.raw.lessons);
1145
+ }
1146
+
1147
+ return ids;
1148
+ }
1149
+
1150
+
1151
+ async function getEligiblePlaylistItems(playlists) {
1152
+ const eligible = playlists.filter(p => p.last_progress && p.last_engaged_on);
1153
+ return Promise.all(
1154
+ eligible.map(async p => {
1155
+ const utcDate = new Date(p.last_progress.replace(' ', 'T') + 'Z');
1156
+ const timestamp = utcDate.getTime();
1157
+ return {
1158
+ type: 'playlist',
1159
+ progressTimestamp: timestamp,
1160
+ last_engaged_on: p.last_engaged_on,
1161
+ raw: p
1162
+ };
1163
+ })
1164
+ );
1165
+ }
1166
+
1167
+ function mergeAndSortItems(items, limit) {
1168
+ return items
1169
+ .filter(item => typeof item.progressTimestamp === 'number' && item.progressTimestamp > 0)
1170
+ .sort((a, b) => b.progressTimestamp - a.progressTimestamp)
1171
+ .slice(0, limit + 5);
1172
+ }
1173
+
1174
+ function findIncompleteLesson(progressOnItems, currentContentId, contentType) {
1175
+ const ids = Object.keys(progressOnItems).map(Number);
1176
+ if (contentType === 'challenge') {
1177
+ // Return first incomplete lesson
1178
+ return ids.find(id => progressOnItems[id] !== 'completed') || null;
1179
+ }
1180
+
1181
+ // For other types, find next incomplete after current
1182
+ const currentIndex = ids.indexOf(Number(currentContentId));
1183
+ if (currentIndex === -1) return null;
1184
+
1185
+ for (let i = currentIndex + 1; i < ids.length; i++) {
1186
+ const id = ids[i];
1187
+ if (progressOnItems[id] !== 'completed') {
1188
+ return id;
1189
+ }
1190
+ }
1191
+
1192
+ return ids[0];
1193
+ }
1194
+
1195
+ /**
1196
+ * Pins a specific progress row for a user, scoped by brand.
1197
+ *
1198
+ * @param {string} brand - The brand context for the pin action.
1199
+ * @param {number|string} id - The ID of the progress item to pin.
1200
+ * @param {string} progressType - The type of progress (e.g., 'content', 'playlist').
1201
+ * @returns {Promise<Object>} - A promise resolving to the response from the pin API.
1202
+ *
1203
+ * @example
1204
+ * pinProgressRow('drumeo', 12345, 'content')
1205
+ * .then(response => console.log(response))
1206
+ * .catch(error => console.error(error));
1207
+ */
1208
+ export async function pinProgressRow(brand, id, progressType) {
1209
+ const url = `/api/user-management-system/v1/progress/pin?brand=${brand}&id=${id}&progressType=${progressType}`;
1210
+ return await fetchHandler(url, 'PUT', null)
1211
+ }
1212
+ /**
1213
+ * Unpins the current pinned progress row for a user, scoped by brand.
1214
+ *
1215
+ * @param {string} brand - The brand context for the unpin action.
1216
+ * @returns {Promise<Object>} - A promise resolving to the response from the unpin API.
1217
+ *
1218
+ * @example
1219
+ * unpinProgressRow('drumeo')
1220
+ * .then(response => console.log(response))
1221
+ * .catch(error => console.error(error));
1222
+ */
1223
+ export async function unpinProgressRow(brand) {
1224
+ const url = `/api/user-management-system/v1/progress/unpin?brand=${brand}`;
1225
+ return await fetchHandler(url, 'PUT', null)
1226
+ }
1227
+
@@ -0,0 +1,182 @@
1
+ {
2
+ "257242": {
3
+ "last_update": 1747657503,
4
+ "progress": 13,
5
+ "status": "started",
6
+ "brand": "drumeo"
7
+ },
8
+ "257243": {
9
+ "last_update": 1747657502,
10
+ "progress": 52,
11
+ "status": "started",
12
+ "brand": "drumeo"
13
+ },
14
+ "257245": {
15
+ "last_update": 1747657470,
16
+ "progress": 62,
17
+ "status": "started",
18
+ "brand": "drumeo"
19
+ },
20
+ "257249": {
21
+ "last_update": 1747657493,
22
+ "progress": 100,
23
+ "status": "completed",
24
+ "brand": "drumeo"
25
+ },
26
+ "257250": {
27
+ "last_update": 1747657502,
28
+ "progress": 100,
29
+ "status": "completed",
30
+ "brand": "drumeo"
31
+ },
32
+ "259238": {
33
+ "last_update": 1747657495,
34
+ "progress": 100,
35
+ "status": "completed",
36
+ "brand": "drumeo"
37
+ },
38
+ "259240": {
39
+ "last_update": 1747657505,
40
+ "progress": 100,
41
+ "status": "completed",
42
+ "brand": "drumeo"
43
+ },
44
+ "260677": {
45
+ "last_update": 1747318330,
46
+ "progress": 100,
47
+ "status": "completed",
48
+ "brand": "drumeo"
49
+ },
50
+ "274368": {
51
+ "last_update": 1747211391,
52
+ "progress": 20,
53
+ "status": "started",
54
+ "brand": "drumeo"
55
+ },
56
+ "274889": {
57
+ "last_update": 1747211391,
58
+ "progress": 100,
59
+ "status": "completed",
60
+ "brand": "drumeo"
61
+ },
62
+ "280498": {
63
+ "last_update": 1747726731,
64
+ "progress": 60,
65
+ "status": "started",
66
+ "brand": "drumeo"
67
+ },
68
+ "287853": {
69
+ "last_update": 1747647688,
70
+ "progress": 100,
71
+ "status": "completed",
72
+ "brand": "drumeo"
73
+ },
74
+ "287855": {
75
+ "last_update": 1747726731,
76
+ "progress": 100,
77
+ "status": "completed",
78
+ "brand": "drumeo"
79
+ },
80
+ "287858": {
81
+ "last_update": 1747653734,
82
+ "progress": 100,
83
+ "status": "completed",
84
+ "brand": "drumeo"
85
+ },
86
+ "316732": {
87
+ "last_update": 1747319258,
88
+ "progress": 0,
89
+ "status": "started",
90
+ "brand": "drumeo"
91
+ },
92
+ "316752": {
93
+ "last_update": 1747402511,
94
+ "progress": 80,
95
+ "status": "started",
96
+ "brand": "drumeo"
97
+ },
98
+ "316772": {
99
+ "last_update": 1747319687,
100
+ "progress": 100,
101
+ "status": "completed",
102
+ "brand": "drumeo"
103
+ },
104
+ "325826": {
105
+ "last_update": 1747324670,
106
+ "progress": 41,
107
+ "status": "started",
108
+ "brand": "drumeo"
109
+ },
110
+ "335025": {
111
+ "last_update": 1747315549,
112
+ "progress": 0,
113
+ "status": "started",
114
+ "brand": "drumeo"
115
+ },
116
+ "373171": {
117
+ "last_update": 1747211114,
118
+ "progress": 100,
119
+ "status": "completed",
120
+ "brand": "drumeo"
121
+ },
122
+ "373172": {
123
+ "last_update": 1747211114,
124
+ "progress": 100,
125
+ "status": "completed",
126
+ "brand": "drumeo"
127
+ },
128
+ "402009": {
129
+ "last_update": 1747293447,
130
+ "progress": 100,
131
+ "status": "completed",
132
+ "brand": "drumeo"
133
+ },
134
+ "402021": {
135
+ "last_update": 1747318769,
136
+ "progress": 100,
137
+ "status": "completed",
138
+ "brand": "drumeo"
139
+ },
140
+ "402199": {
141
+ "last_update": 1747318734,
142
+ "progress": 0,
143
+ "status": "started",
144
+ "brand": "drumeo"
145
+ },
146
+ "410255": {
147
+ "last_update": 1747211224,
148
+ "progress": 100,
149
+ "status": "completed",
150
+ "brand": "drumeo"
151
+ },
152
+ "410478": {
153
+ "last_update": 1747657306,
154
+ "progress": 22,
155
+ "status": "started",
156
+ "brand": "drumeo"
157
+ },
158
+ "410480": {
159
+ "last_update": 1747657295,
160
+ "progress": 100,
161
+ "status": "completed",
162
+ "brand": "drumeo"
163
+ },
164
+ "410481": {
165
+ "last_update": 1747657306,
166
+ "progress": 100,
167
+ "status": "completed",
168
+ "brand": "drumeo"
169
+ },
170
+ "411091": {
171
+ "last_update": 1747318457,
172
+ "progress": 100,
173
+ "status": "completed",
174
+ "brand": "drumeo"
175
+ },
176
+ "415585": {
177
+ "last_update": 1747319147,
178
+ "progress": 100,
179
+ "status": "completed",
180
+ "brand": "drumeo"
181
+ }
182
+ }