Import Instagram archive to a Bluesky account
9
fork

Configure Feed

Select the types of activity you want to include in your feed.

Refactor Instagram media processing with strategy pattern and improved type handling

- Implement strategy pattern for processing Instagram media (images and videos)
- Add separate processors for image and video media types
- Update InstagramExportedPost interface to support single media or media array
- Improve error handling and media buffer retrieval
- Simplify media processing logic with dedicated processing strategies

+196 -219
+1 -1
src/media/InstagramExportedPost.ts
··· 56 56 * Represents posts_1.json data export from Instagram as of 2025/02/11. 57 57 */ 58 58 export interface InstagramExportedPost extends CreationTimestamp { 59 - media: Media[]; 59 + media: Media[] | Media; 60 60 title: string; 61 61 }
+5 -6
src/media/ProcessedPost.ts
··· 1 - import { MediaProcessResult } from './MediaProcessResult.js'; 1 + import { MediaProcessResult } from "./MediaProcessResult.js"; 2 2 3 3 export interface ProcessedPost { 4 4 postDate: Date | null; 5 5 postText: string; 6 - embeddedMedia: MediaProcessResult | MediaProcessResult[]; 6 + embeddedMedia: MediaProcessResult | MediaProcessResult[] | undefined; 7 7 mediaCount: number; 8 8 } 9 9 10 10 // Implementation of the ProcessedPost interface 11 11 export class ProcessedPostImpl implements ProcessedPost { 12 + public mediaCount: number = 0; 13 + public embeddedMedia: MediaProcessResult | MediaProcessResult[] | undefined; 12 14 constructor( 13 15 public postDate: Date | null, 14 - public postText: string, 15 - public embeddedMedia: MediaProcessResult | MediaProcessResult[], 16 - public mediaCount: number 16 + public postText: string 17 17 ) {} 18 18 } 19 -
+190 -212
src/media/media.ts
··· 2 2 3 3 import { logger } from "@logger/logger.js"; 4 4 import { validateVideo } from "@video/video.js"; 5 - import { ProcessedPost } from "./ProcessedPost.js"; 6 - import { MediaProcessResult, MediaProcessResultImpl } from "./MediaProcessResult.js"; 7 - import { InstagramExportedPost, Media } from "./InstagramExportedPost.js"; 5 + import { ProcessedPost, ProcessedPostImpl } from "./ProcessedPost.js"; 6 + import { 7 + MediaProcessResult, 8 + ImageMediaProcessResultImpl, 9 + VideoMediaProcessResultImpl, 10 + } from "./MediaProcessResult.js"; 11 + import { 12 + ImageMedia, 13 + InstagramExportedPost, 14 + Media, 15 + VideoMedia, 16 + } from "./InstagramExportedPost.js"; 8 17 // TODO make a stratgey pattern for video versus image 9 18 const MAX_IMAGES_PER_POST = 4; 10 19 const POST_TEXT_LIMIT = 300; 11 20 const POST_TEXT_TRUNCATE_SUFFIX = "..."; 21 + const UNSUPPORTED_FILE_TYPE_ERROR = Error(`Unsupported file type`); 12 22 13 - interface MediaProcessingStrategy { 23 + /** 24 + * Strategy pattern interface to allow all medias and posts to share a common method of process. 25 + */ 26 + interface ProcessStrategy<P> { 14 27 /** 15 - * Processes instagram post and media data into a format easily mapped to Blueskys requirements. 28 + * Processes instagram data into a format easily mapped to Blueskys requirements. 16 29 */ 17 - process(): Promise<ProcessedPost>; 30 + process(): Promise<P>; 31 + } 32 + 33 + interface MIMEType { 18 34 /** 19 35 * Determine the MIME type of the media file. 20 36 */ 21 37 getMimeType(fileType: string): string; 22 38 } 23 39 40 + /** 41 + * Processes instagram posts with media to embed. 42 + */ 43 + interface InstagramPostProcessingStrategy 44 + extends ProcessStrategy<ProcessedPost[]> { 45 + /** 46 + * Processes instagram post and media data into a format easily mapped to Blueskys requirements. 47 + */ 48 + process(): Promise<ProcessedPost[]>; 49 + } 24 50 25 - export class InstagramMediaProcessor implements MediaProcessingStrategy { 51 + /** 52 + * Processes many images in a post into a normalized MediaProcessResult[]. 53 + */ 54 + interface ImageMediaProcessingStrategy 55 + extends ProcessStrategy<MediaProcessResult[]>, 56 + MIMEType {} 57 + 58 + /** 59 + * Processes single video post media into a normalized MediaProcessResult. 60 + */ 61 + interface VideoMediaProcessingStrategy 62 + extends ProcessStrategy<MediaProcessResult>, 63 + MIMEType {} 64 + 65 + export class InstagramMediaProcessor 66 + implements InstagramPostProcessingStrategy 67 + { 68 + constructor( 69 + public instagramPosts: InstagramExportedPost[], 70 + public archiveFolder: string 71 + ) {} 26 72 27 - constructor(public instagramPosts: InstagramExportedPost[]){} 73 + public process(): Promise<ProcessedPost[]> { 74 + const processingPosts: ProcessedPost[] = []; 75 + for(const post of this.instagramPosts){ 76 + const postDate = new Date(post.creation_timestamp * 1000); 77 + const processingPost = new ProcessedPostImpl(postDate, post.title); 78 + let processingMedia: Promise<MediaProcessResult | MediaProcessResult[]>; 79 + // If its an array we know we have many images. 80 + // Does not support images and videos in the same post at this time. 81 + if(Array.isArray(post.media)) { 82 + const imageProcessor = new InstagramImageProcessor(post.media, this.archiveFolder); 83 + const processingImages: Promise<MediaProcessResult[]> = imageProcessor.process(); 84 + processingImages.then((processedImages) => processingPost.embeddedMedia = processedImages); 85 + processingMedia = processingImages; 86 + } else { 87 + // If its not an array we know we have a single video post 88 + // A strong assumption that should be changed once both medias are handled the same. 89 + const videoProcessor = new InstagramVideoProcessor(post.media as VideoMedia, this.archiveFolder); 90 + const processingVideo: Promise<MediaProcessResult> = videoProcessor.process(); 91 + processingVideo.then((processedVideo)=> processingPost.embeddedMedia = processedVideo); 92 + processingMedia = processingVideo; 93 + } 94 + 95 + processingPosts.push(processingPost); 96 + } 97 + 98 + return Promise.resolve(processingPosts); 99 + } 100 + } 101 + 102 + export class InstagramImageProcessor implements ImageMediaProcessingStrategy { 103 + constructor( 104 + public instagramImages: ImageMedia[], 105 + public archiveFolder: string 106 + ) {} 107 + process(): Promise<MediaProcessResult[]> { 108 + const processingResults: Promise<MediaProcessResult>[] = []; 109 + // Iterate over each image in the post, 110 + // adding the process to the promise array. 111 + for (const media of this.instagramImages) { 112 + const processedMedia = this.processMedia(media, this.archiveFolder); 113 + processingResults.push(processedMedia); 114 + } 28 115 29 - public process(): Promise<ProcessedPost> { 30 - throw Error('Unimplemented strategy pattern.'); 116 + // Return all images being processed as a single promise. 117 + return Promise.all(processingResults); 31 118 } 32 119 33 120 public getMimeType(fileType: string): string { 34 121 switch (fileType.toLowerCase()) { 35 - // TODO move image formats into a the image layer. 36 122 case "heic": 37 123 return "image/heic"; 38 124 case "webp": 39 125 return "image/webp"; 40 126 case "jpg": 41 127 return "image/jpeg"; 42 - // TODO move video formats into the video layer. 43 - case "mp4": 44 - return "video/mp4"; 45 - case "mov": 46 - return "video/quicktime"; 47 128 default: 48 - throw Error(`Unsupported file type: ${fileType}`); 49 - } 50 - } 51 - } 52 - 53 - /** 54 - * Transforms media (image(s)/video) from social media format to a object that can be uploaded to bluesky to become embedded media. 55 - * @param media 56 - * @param archiveFolder 57 - * @returns 58 - */ 59 - export async function processMedia( 60 - media: Media, 61 - archiveFolder: string 62 - ): Promise<MediaProcessResult> { 63 - const mediaDate = new Date(media.creation_timestamp * 1000); 64 - const fileType = media.uri.substring(media.uri.lastIndexOf(".") + 1); 65 - const mimeType = new InstagramMediaProcessor({}).getMimeType(fileType); 66 - const mediaFilename = `${archiveFolder}/${media.uri}`; 67 - 68 - let mediaBuffer; 69 - try { 70 - mediaBuffer = FS.readFileSync(mediaFilename); 71 - } catch (error) { 72 - logger.error({ 73 - message: `Failed to read media file: ${mediaFilename}`, 74 - error, 75 - }); 76 - return new MediaProcessResultImpl("", null, null, false); 77 - } 78 - 79 - let mediaText = media.title ?? ""; 80 - if (media.media_metadata?.photo_metadata?.exif_data && media.media_metadata.photo_metadata.exif_data.length > 0) { 81 - const location = media.media_metadata.photo_metadata.exif_data[0]; 82 - const { latitude, longitude } = location || {}; 83 - if (latitude && latitude > 0) { 84 - mediaText += `\nPhoto taken at these geographical coordinates: geo:${latitude},${longitude}`; 129 + logger.warn(`Unsupported File type ${fileType}`); 130 + throw UNSUPPORTED_FILE_TYPE_ERROR; 85 131 } 86 132 } 87 133 88 - const truncatedText = 89 - mediaText.length > 100 ? mediaText.substring(0, 100) + "..." : mediaText; 90 - 91 - const isVideo = mimeType.startsWith("video/"); 92 - 93 - logger.debug({ 94 - message: "Instagram Source Media", 95 - mimeType, 96 - mediaFilename, 97 - Created: `${mediaDate.toISOString()}`, 98 - Text: truncatedText.replace(/[\r\n]+/g, " ") || "No title", 99 - Type: isVideo ? "Video" : "Image", 100 - }); 101 - 102 - return new MediaProcessResultImpl(truncatedText, mimeType, mediaBuffer, isVideo); 103 - } 104 - 105 - /** 106 - * Transforms post content from social media format into the bluesky post format. 107 - * @param post 108 - * @param archiveFolder 109 - * @param bluesky 110 - * @param simulate 111 - * @returns 112 - */ 113 - export async function processPost( 114 - post: any, 115 - archiveFolder: string 116 - ): Promise<ProcessedPost> { 117 - let postDate = post.creation_timestamp 118 - ? new Date(post.creation_timestamp * 1000) 119 - : undefined; 120 - let postText = post.title ?? ""; 121 - 122 - if (postText.length > POST_TEXT_LIMIT) { 123 - postText = 124 - postText.substring( 125 - 0, 126 - POST_TEXT_LIMIT - POST_TEXT_TRUNCATE_SUFFIX.length 127 - ) + POST_TEXT_TRUNCATE_SUFFIX; 128 - } 129 - 130 - if (!post.media?.length) { 131 - return { 132 - postDate: postDate || null, 133 - postText, 134 - embeddedMedia: [], 135 - mediaCount: 0, 136 - }; 137 - } 138 - 139 - if (post.media.length === 1) { 140 - postText = postText || post.media[0].title; 141 - postDate = postDate || new Date(post.media[0].creation_timestamp * 1000); 142 - } 134 + /** 135 + * Transforms image from instragrams export to a normalized processed result. 136 + * @param media 137 + * @param archiveFolder 138 + * @returns Promise<MediaProcessResult> 139 + */ 140 + private async processMedia( 141 + media: Media, 142 + archiveFolder: string 143 + ): Promise<ImageMediaProcessResultImpl> { 144 + const fileType = media.uri.substring(media.uri.lastIndexOf(".") + 1); 145 + const mimeType = this.getMimeType(fileType); 143 146 144 - let embeddedMedia: MediaProcessResult[] = []; 145 - let mediaCount = 0; 147 + const mediaBuffer = getMediaBuffer(archiveFolder, media); 146 148 147 - // If first media is video, process only that 148 - const firstMedia = await processMedia(post.media[0], archiveFolder); 149 - if (firstMedia.isVideo) { 150 - let embeddedVideo: MediaProcessResult; 149 + let mediaText = media.title ?? ""; 151 150 if ( 152 - firstMedia.mimeType && 153 - firstMedia.mediaBuffer && 154 - validateVideo(firstMedia.mediaBuffer) 151 + media.media_metadata?.photo_metadata?.exif_data && 152 + media.media_metadata.photo_metadata.exif_data.length > 0 155 153 ) { 156 - embeddedVideo = new MediaProcessResultImpl( 157 - firstMedia.mediaText, 158 - firstMedia.mimeType, 159 - firstMedia.mediaBuffer, 160 - true 161 - ); 162 - mediaCount = 1; 163 - // Handle video if present 164 - try { 165 - const videoEmbed = await processVideoPost( 166 - post.media[0].uri, 167 - firstMedia.mediaBuffer, 168 - bluesky, 169 - simulate 170 - ); 171 - 172 - embeddedVideo = videoEmbed as MediaProcessResult; 173 - logger.debug({ 174 - message: "Video processing complete", 175 - hasVideoEmbed: !!videoEmbed, 176 - }); 177 - } catch (error) { 178 - logger.error("Failed to process video:", error); 154 + const location = media.media_metadata.photo_metadata.exif_data[0]; 155 + const { latitude, longitude } = location || {}; 156 + if (latitude && latitude > 0) { 157 + mediaText += `\nPhoto taken at these geographical coordinates: geo:${latitude},${longitude}`; 179 158 } 180 - return { 181 - postDate: postDate || null, 182 - postText, 183 - embeddedMedia: embeddedVideo, 184 - mediaCount, 185 - }; 186 159 } 187 - } 188 160 189 - // Otherwise process images 190 - for (let j = 0; j < post.media.length; j++) { 191 - if (j >= MAX_IMAGES_PER_POST) { 192 - logger.warn( 193 - "Bluesky does not support more than 4 images per post, excess images will be discarded." 194 - ); 195 - break; 196 - } 197 - 198 - const { mediaText, mimeType, mediaBuffer, isVideo } = await processMedia( 199 - post.media[j], 200 - archiveFolder 201 - ); 161 + const truncatedText = 162 + mediaText.length > 100 ? mediaText.substring(0, 100) + "..." : mediaText; 202 163 203 - if (!mimeType || !mediaBuffer || isVideo) continue; 204 - 205 - embeddedMedia.push( 206 - new MediaProcessResultImpl( 207 - mediaText, 208 - mimeType, 209 - mediaBuffer, 210 - false 211 - ) 164 + return new ImageMediaProcessResultImpl( 165 + truncatedText, 166 + mimeType, 167 + mediaBuffer! 212 168 ); 213 - mediaCount++; 214 169 } 215 - 216 - return { 217 - postDate: postDate || null, 218 - postText, 219 - embeddedMedia, 220 - mediaCount, 221 - }; 222 170 } 223 171 224 - 172 + export class InstagramVideoProcessor implements VideoMediaProcessingStrategy { 173 + constructor( 174 + public instagramVideo: VideoMedia, 175 + public archiveFolder: string 176 + ) {} 177 + process(): Promise<MediaProcessResult> { 178 + const processingVideo = this.processVideoMedia( 179 + this.instagramVideo, 180 + this.archiveFolder 181 + ); 225 182 226 - /** 227 - * Processes a video file for posting to Bluesky. 228 - * 229 - * @param filePath - The path to the video file being processed 230 - * @param buffer - The video file contents as a Buffer 231 - * @param bluesky - BlueskyClient instance for uploading, or null if not uploading 232 - * @param simulate - If true, skips the actual upload to Bluesky 233 - * 234 - * @returns A video embed structure ready for posting to Bluesky 235 - * @throws {Error} If video buffer is undefined or upload fails 236 - */ 237 - export async function processVideoPost( 238 - filePath: string, 239 - buffer: Buffer, 240 - bluesky: BlueskyClient | null, 241 - simulate: boolean 242 - ): ProcessedPost { 243 - try { 244 - if (!buffer) { 245 - throw new Error("Video buffer is undefined"); 246 - } 247 - logger.debug({ 248 - message: "Processing video", 249 - fileSize: buffer.length, 250 - filePath, 251 - }); 183 + return processingVideo; 184 + } 252 185 253 - // 254 - if (!validateVideo(buffer)) { 255 - throw new Error('Video validation failed'); 186 + public getMimeType(fileType: string): string { 187 + switch (fileType.toLowerCase()) { 188 + case "mp4": 189 + return "video/mp4"; 190 + case "mov": 191 + return "video/quicktime"; 192 + default: 193 + logger.warn(`Unsupported File type ${fileType}`); 194 + throw UNSUPPORTED_FILE_TYPE_ERROR; 256 195 } 196 + } 257 197 198 + /** 199 + * Transforms post content from social media format into the bluesky post format. 200 + * @param post 201 + * @param archiveFolder 202 + * @param bluesky 203 + * @param simulate 204 + * @returns Promise<VideoMediaProcessResultImpl> 205 + */ 206 + private async processVideoMedia( 207 + media: VideoMedia, 208 + archiveFolder: string 209 + ): Promise<VideoMediaProcessResultImpl> { 210 + const fileType = media.uri.substring(media.uri.lastIndexOf(".") + 1); 211 + const mimeType = this.getMimeType(fileType); 258 212 213 + const mediaBuffer = getMediaBuffer(archiveFolder, media); 259 214 260 - } catch (error) { 261 - logger.error("Failed to process video:", error); 262 - throw error; 215 + return new VideoMediaProcessResultImpl(media.title, mimeType, mediaBuffer!); 263 216 } 264 217 } 265 218 266 219 /** 267 220 * Decode JSON Data into an Object. 268 221 * @param data 269 - * @returns 222 + * @returns 270 223 */ 271 224 export function decodeUTF8(data: any): any { 272 225 try { ··· 292 245 logger.error({ message: "Error decoding UTF-8 data", error }); 293 246 return data; 294 247 } 295 - } 248 + } 249 + 250 + /** 251 + * Reads the instagram media file from the archive folder and media file name in export data. 252 + * @param archiveFolder 253 + * @param media 254 + * @returns 255 + */ 256 + export function getMediaBuffer( 257 + archiveFolder: string, 258 + media: Media 259 + ): Buffer | undefined { 260 + const mediaFilename = `${archiveFolder}/${media.uri}`; 261 + 262 + let mediaBuffer; 263 + try { 264 + mediaBuffer = FS.readFileSync(mediaFilename); 265 + } catch (error) { 266 + logger.error({ 267 + message: `Failed to read media file: ${mediaFilename}`, 268 + error, 269 + }); 270 + } 271 + 272 + return mediaBuffer; 273 + }