Skip to content

Commit 64c1651

Browse files
feat(reddit): add support for multi-image gallery posts
- Allow scheduling Reddit posts with 2-20 images (posted as gallery) - Update uploadFileToReddit() to capture asset IDs for gallery items - Implement gallery branch with fallback to single image - Add 1 second delay between asset uploads (respects 1 req/sec limit) - Update frontend validation to allow multiple images - Add defensive WebSocket guard for missing URLs - Preserve existing single-image and video post behavior Fixes limitation where Reddit posts could only be scheduled with a single media item.
1 parent 79de287 commit 64c1651

File tree

2 files changed

+208
-59
lines changed

2 files changed

+208
-59
lines changed

apps/frontend/src/components/new-launch/providers/reddit/reddit.provider.tsx

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -215,21 +215,44 @@ export default withProvider({
215215
CustomPreviewComponent: undefined,
216216
dto: RedditSettingsDto,
217217
checkValidity: async (posts, settings: any) => {
218-
if (
219-
settings?.subreddit?.some(
220-
(p: any, index: number) =>
221-
p?.value?.type === 'media' && posts[0].length !== 1
222-
)
223-
) {
224-
return 'When posting a media post, you must attached exactly one media file.';
225-
}
218+
// Check if type is media in any subreddit setting
219+
const hasMediaType = settings?.subreddit?.some(
220+
(p: any) => p?.value?.type === 'media'
221+
);
226222

227-
if (
228-
posts.some((p) =>
229-
p.some((a) => !a.thumbnail && a.path.indexOf('mp4') > -1)
230-
)
231-
) {
232-
return 'You must attach a thumbnail to your video post.';
223+
if (hasMediaType && posts[0]) {
224+
const firstPost = posts[0];
225+
226+
// Detect video and image media
227+
const hasVideo = firstPost.some((m: any) => m.path.indexOf('mp4') > -1);
228+
const hasImage = firstPost.some((m: any) => m.path.indexOf('mp4') === -1);
229+
230+
// Disallow mixed media (video + images)
231+
if (hasVideo && hasImage) {
232+
return 'You cannot mix videos and images in a Reddit post. Please use either a video or images, not both.';
233+
}
234+
235+
// Video validation
236+
if (hasVideo) {
237+
if (firstPost.length !== 1) {
238+
return 'When posting a video, you must attach exactly one media file.';
239+
}
240+
241+
// Check for thumbnail
242+
const videoMedia = firstPost.find((m: any) => m.path.indexOf('mp4') > -1);
243+
if (!videoMedia?.thumbnail) {
244+
return 'You must attach a thumbnail to your video post.';
245+
}
246+
} else {
247+
// Image validation (no video)
248+
if (firstPost.length === 0) {
249+
return 'When posting a media post, you must attach at least one image.';
250+
}
251+
252+
if (firstPost.length > 20) {
253+
return 'Reddit allows a maximum of 20 images per post.';
254+
}
255+
}
233256
}
234257

235258
return true;

libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts

Lines changed: 171 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -123,15 +123,13 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
123123
};
124124
}
125125

126-
private async uploadFileToReddit(accessToken: string, path: string) {
126+
private async uploadFileToReddit(accessToken: string, path: string): Promise<{ url: string; assetId?: string }> {
127127
const mimeType = lookup(path);
128128
const formData = new FormData();
129129
formData.append('filepath', path.split('/').pop());
130130
formData.append('mimetype', mimeType || 'application/octet-stream');
131131

132-
const {
133-
args: { action, fields },
134-
} = await (
132+
const responseData = await (
135133
await this.fetch(
136134
'https://oauth.reddit.com/api/media/asset',
137135
{
@@ -147,6 +145,11 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
147145
)
148146
).json();
149147

148+
const {
149+
args: { action, fields },
150+
asset,
151+
} = responseData;
152+
150153
const { data } = await axios.get(path, {
151154
responseType: 'arraybuffer',
152155
});
@@ -169,7 +172,12 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
169172
body: upload,
170173
});
171174

172-
return [...(await d.text()).matchAll(/<Location>(.*?)<\/Location>/g)][0][1];
175+
const url = [...(await d.text()).matchAll(/<Location>(.*?)<\/Location>/g)][0][1];
176+
177+
// Extract asset_id from the asset object if available
178+
const assetId = asset?.asset_id || asset?.id;
179+
180+
return { url, assetId };
173181
}
174182

175183
async post(
@@ -181,53 +189,165 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
181189

182190
const valueArray: PostResponse[] = [];
183191
for (const firstPostSettings of post.settings.subreddit) {
184-
const postData = {
192+
// Detect media type
193+
const hasVideo = post.media?.some((m) => m.path.indexOf('mp4') > -1) === true;
194+
const images = (post.media || []).filter((m) => m.path.indexOf('mp4') === -1);
195+
196+
// Determine kind based on media type
197+
let kind: string = firstPostSettings.value.type;
198+
if (firstPostSettings.value.type === 'media') {
199+
if (hasVideo) {
200+
kind = 'video';
201+
} else if (images.length > 1) {
202+
kind = 'gallery';
203+
} else {
204+
kind = 'image';
205+
}
206+
}
207+
208+
// Build postData
209+
let postData: any = {
185210
api_type: 'json',
186211
title: firstPostSettings.value.title || '',
187-
kind:
188-
firstPostSettings.value.type === 'media'
189-
? post.media[0].path.indexOf('mp4') > -1
190-
? 'video'
191-
: 'image'
192-
: firstPostSettings.value.type,
212+
kind,
213+
text: post.message,
214+
sr: firstPostSettings.value.subreddit,
193215
...(firstPostSettings.value.flair
194216
? { flair_id: firstPostSettings.value.flair.id }
195217
: {}),
196-
...(firstPostSettings.value.type === 'link'
197-
? {
198-
url: firstPostSettings.value.url,
199-
}
200-
: {}),
201-
...(firstPostSettings.value.type === 'media'
202-
? {
203-
url: await this.uploadFileToReddit(
204-
accessToken,
205-
post.media[0].path
206-
),
207-
...(post.media[0].path.indexOf('mp4') > -1
208-
? {
209-
video_poster_url: await this.uploadFileToReddit(
210-
accessToken,
211-
post.media[0].thumbnail
212-
),
213-
}
214-
: {}),
215-
}
216-
: {}),
217-
text: post.message,
218-
sr: firstPostSettings.value.subreddit,
219218
};
220219

221-
const all = await (
222-
await this.fetch('https://oauth.reddit.com/api/submit', {
223-
method: 'POST',
224-
headers: {
225-
Authorization: `Bearer ${accessToken}`,
226-
'Content-Type': 'application/x-www-form-urlencoded',
227-
},
228-
body: new URLSearchParams(postData),
229-
})
230-
).json();
220+
// Add type-specific fields
221+
if (firstPostSettings.value.type === 'link') {
222+
postData.url = firstPostSettings.value.url;
223+
} else if (firstPostSettings.value.type === 'media') {
224+
if (kind === 'gallery') {
225+
// Gallery: upload all images and build items array
226+
const uploads: { url: string; assetId?: string }[] = [];
227+
for (let i = 0; i < images.length; i++) {
228+
const uploaded = await this.uploadFileToReddit(accessToken, images[i].path);
229+
uploads.push(uploaded);
230+
231+
// Add delay between Reddit API calls to respect rate limit (1 req/sec)
232+
if (i < images.length - 1) {
233+
await timer(1000);
234+
}
235+
}
236+
237+
// Build items array with media_ids
238+
const items = uploads
239+
.filter((u) => u.assetId)
240+
.map((u) => ({ media_id: u.assetId }));
241+
242+
// Reddit requires at least 2 items for a gallery
243+
if (items.length >= 2) {
244+
postData.items = JSON.stringify(items);
245+
} else {
246+
// Fallback: if less than 2 asset IDs, use first image as single image
247+
console.warn(`Reddit gallery: only ${items.length} valid asset ID(s), falling back to single image`);
248+
kind = 'image';
249+
postData.kind = 'image';
250+
postData.url = uploads[0].url;
251+
}
252+
} else if (kind === 'video') {
253+
// Video: upload video and thumbnail
254+
const videoUpload = await this.uploadFileToReddit(accessToken, post.media[0].path);
255+
postData.url = videoUpload.url;
256+
257+
if (post.media[0].thumbnail) {
258+
const thumbnailUpload = await this.uploadFileToReddit(accessToken, post.media[0].thumbnail);
259+
postData.video_poster_url = thumbnailUpload.url;
260+
}
261+
} else {
262+
// Single image
263+
const imageUpload = await this.uploadFileToReddit(accessToken, post.media[0].path);
264+
postData.url = imageUpload.url;
265+
}
266+
}
267+
268+
// Try to submit, with fallback for gallery
269+
let all: any;
270+
try {
271+
all = await (
272+
await this.fetch('https://oauth.reddit.com/api/submit', {
273+
method: 'POST',
274+
headers: {
275+
Authorization: `Bearer ${accessToken}`,
276+
'Content-Type': 'application/x-www-form-urlencoded',
277+
},
278+
body: new URLSearchParams(postData),
279+
})
280+
).json();
281+
282+
// Check for gallery-specific errors and fallback to single image
283+
if (
284+
kind === 'gallery' &&
285+
all?.json?.errors?.length > 0 &&
286+
(all.json.errors.some((e: any) =>
287+
e[0] === 'INVALID_OPTION' ||
288+
e[0] === 'BAD_GALLERY' ||
289+
(typeof e[1] === 'string' && e[1].toLowerCase().includes('gallery'))
290+
))
291+
) {
292+
console.warn('Reddit gallery not supported in this subreddit, falling back to single image');
293+
294+
// Fallback: post as single image using first image
295+
const firstImageUpload = await this.uploadFileToReddit(accessToken, images[0].path);
296+
postData = {
297+
api_type: 'json',
298+
title: firstPostSettings.value.title || '',
299+
kind: 'image',
300+
url: firstImageUpload.url,
301+
text: post.message,
302+
sr: firstPostSettings.value.subreddit,
303+
...(firstPostSettings.value.flair
304+
? { flair_id: firstPostSettings.value.flair.id }
305+
: {}),
306+
};
307+
308+
all = await (
309+
await this.fetch('https://oauth.reddit.com/api/submit', {
310+
method: 'POST',
311+
headers: {
312+
Authorization: `Bearer ${accessToken}`,
313+
'Content-Type': 'application/x-www-form-urlencoded',
314+
},
315+
body: new URLSearchParams(postData),
316+
})
317+
).json();
318+
}
319+
} catch (err) {
320+
// If any error occurs during gallery submission, fallback to single image
321+
if (kind === 'gallery' && images.length > 0) {
322+
console.warn('Reddit gallery submission failed, falling back to single image:', err);
323+
324+
const firstImageUpload = await this.uploadFileToReddit(accessToken, images[0].path);
325+
postData = {
326+
api_type: 'json',
327+
title: firstPostSettings.value.title || '',
328+
kind: 'image',
329+
url: firstImageUpload.url,
330+
text: post.message,
331+
sr: firstPostSettings.value.subreddit,
332+
...(firstPostSettings.value.flair
333+
? { flair_id: firstPostSettings.value.flair.id }
334+
: {}),
335+
};
336+
337+
all = await (
338+
await this.fetch('https://oauth.reddit.com/api/submit', {
339+
method: 'POST',
340+
headers: {
341+
Authorization: `Bearer ${accessToken}`,
342+
'Content-Type': 'application/x-www-form-urlencoded',
343+
},
344+
body: new URLSearchParams(postData),
345+
})
346+
).json();
347+
} else {
348+
throw err;
349+
}
350+
}
231351

232352
const { id, name, url } = await new Promise<{
233353
id: string;
@@ -238,6 +358,12 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
238358
res(all.json.data);
239359
}
240360

361+
// Guard against missing websocket_url
362+
if (!all?.json?.data?.websocket_url) {
363+
res({ id: '', name: '', url: '' });
364+
return;
365+
}
366+
241367
const ws = new WebSocket(all.json.data.websocket_url);
242368
ws.on('message', (data: any) => {
243369
setTimeout(() => {

0 commit comments

Comments
 (0)