Import Instagram archive to a Bluesky account
9
fork

Configure Feed

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

Refactor media processing and testing with improved processor strategies

- Update InstagramMediaProcessor to handle multiple media types
- Modify VideoProcessor and ImageProcessor to support array processing
- Remove hardcoded file type validation and use utility functions
- Simplify media processing logic and error handling
- Update test suite to reflect new processing strategies
- Remove unnecessary mocks and improve test coverage

+152 -126
+2 -2
src/bluesky/types/EmbeddedMedia.ts
··· 1 - import { VideoEmbed } from "./VideoEmbed.js"; 2 - import { ImagesEmbed } from "./ImagesEmbed.js"; 1 + import { VideoEmbed } from "./VideoEmbed"; 2 + import { ImagesEmbed } from "./ImagesEmbed"; 3 3 4 4 export type EmbeddedMedia = VideoEmbed | ImagesEmbed;
+1 -1
src/bluesky/types/ImagesEmbed.ts
··· 1 1 import { AppBskyEmbedImages } from "@atproto/api"; 2 - import { ImageEmbed } from "./ImageEmbed.js"; 2 + import { ImageEmbed } from "./ImageEmbed"; 3 3 4 4 export interface ImagesEmbed extends AppBskyEmbedImages.Main { 5 5 $type: "app.bsky.embed.images";
+1 -1
src/bluesky/types/PostRecord.ts
··· 1 1 import { AppBskyFeedPost, Facet } from "@atproto/api"; 2 - import { EmbeddedMedia } from "./EmbeddedMedia.js"; 2 + import { EmbeddedMedia } from "./EmbeddedMedia"; 3 3 4 4 export interface PostRecord extends Partial<AppBskyFeedPost.Record> {} 5 5
-6
src/bluesky/types/VideoEmbed.ts
··· 2 2 3 3 export interface VideoEmbed extends AppBskyEmbedVideo.Main { 4 4 $type: "app.bsky.embed.video"; 5 - buffer: Buffer; 6 5 mimeType: string; 7 6 video: BlobRef; 8 - size?: number; 9 7 captions?: AppBskyEmbedVideo.Caption[]; 10 8 alt?: string; 11 9 aspectRatio?: AppBskyEmbedDefs.AspectRatio; ··· 17 15 18 16 constructor( 19 17 public alt: string | undefined, 20 - public buffer: Buffer, 21 18 public mimeType: string, 22 - public size: number | undefined, 23 19 public video: BlobRef, 24 20 public aspectRatio?: AppBskyEmbedDefs.AspectRatio, 25 21 public captions?: AppBskyEmbedVideo.Caption[] ··· 29 25 return { 30 26 $type: this.$type, 31 27 alt: this.alt, 32 - buffer: "[Buffer length=" + this.buffer.length + "]", 33 28 mimeType: this.mimeType, 34 - size: this.size, 35 29 video: this.video, 36 30 }; 37 31 }
+1 -1
src/image/image.ts
··· 1 1 import sharp from "sharp"; 2 2 import byteSize from "byte-size"; 3 - import { logger } from "@logger/logger.js"; 3 + import { logger } from "../logger/logger"; 4 4 5 5 /** 6 6 * Image lexicon maxSize 1mb
+1 -1
src/image/index.ts
··· 1 - export * from './image.js'; 1 + export * from './image';
-20
src/instagram-to-bluesky.test.ts
··· 242 242 243 243 (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify([mockPost])); 244 244 245 - jest.mocked(InstagramMediaProcessor).mockImplementation(() => ({ 246 - mediaProcessorFactory: { 247 - createProcessor: () => ({ 248 - process: jest.fn().mockResolvedValue([{ 249 - mediaText: 'Test media', 250 - mimeType: 'image/jpeg', 251 - mediaBuffer: Buffer.from('test') 252 - }]) 253 - }) 254 - }, 255 - instagramPosts: [], 256 - archiveFolder: '', 257 - process: jest.fn().mockResolvedValue([{ 258 - postDate: new Date(), 259 - postText: 'Test post', 260 - embeddedMedia: [], 261 - mediaCount: 10 // 10 media items 262 - }]) 263 - })); 264 - 265 245 await main(); 266 246 267 247 expect(logger.info).toHaveBeenCalledWith(
-2
src/instagram-to-bluesky.ts
··· 232 232 ); 233 233 uploadedMedia = new VideoEmbedImpl( 234 234 postText, 235 - mediaBuffer!, 236 235 mimeType!, 237 - mediaBuffer?.length, 238 236 blobRef, 239 237 { width: 640, height: 640 } 240 238 );
+1 -1
src/main.ts
··· 1 - import { main } from "./instagram-to-bluesky.js"; 1 + import { main } from "./instagram-to-bluesky"; 2 2 3 3 (async () => { 4 4 await main();
+13 -5
src/media/ProcessedPost.ts
··· 1 - import { MediaProcessResult } from "./MediaProcessResult.js"; 1 + import { MediaProcessResult } from "./MediaProcessResult"; 2 2 3 + /** 4 + * Normalized post structure. 5 + */ 3 6 export interface ProcessedPost { 4 7 postDate: Date | null; 5 8 postText: string; 6 - embeddedMedia: MediaProcessResult | MediaProcessResult[] | undefined; 9 + embeddedMedia: MediaProcessResult[]; 7 10 mediaCount: number; 8 11 } 9 12 10 - // Implementation of the ProcessedPost interface 13 + /** 14 + * Processed post with media count based on embedded media. 15 + */ 16 + 11 17 export class ProcessedPostImpl implements ProcessedPost { 12 - public mediaCount: number = 0; 13 - public embeddedMedia: MediaProcessResult | MediaProcessResult[] | undefined; 18 + public embeddedMedia: MediaProcessResult[] = []; 19 + get mediaCount(): number { 20 + return this.embeddedMedia.length; 21 + }; 14 22 constructor( 15 23 public postDate: Date | null, 16 24 public postText: string
+3 -3
src/media/index.ts
··· 1 - export * from './MediaProcessResult.js'; 2 - export * from './ProcessedPost.js'; 3 - export * from './media.js'; 1 + export * from './MediaProcessResult'; 2 + export * from './ProcessedPost'; 3 + export * from './media';
+45 -29
src/media/media.test.ts
··· 18 18 }, 19 19 })); 20 20 21 - // Mock the video validation 22 - jest.mock("../video/video", () => ({ 23 - validateVideo: jest.fn().mockReturnValue(false) 24 - })); 25 - 26 21 describe("Instagram Media Processing", () => { 27 22 beforeEach(() => { 28 23 jest.clearAllMocks(); ··· 69 64 const mockPost: InstagramExportedPost = { 70 65 creation_timestamp: 1234567890, 71 66 title: "Test Video Post", 72 - media: { 67 + media: [{ 73 68 uri: "video.mp4", 74 69 title: "Test Video", 75 70 creation_timestamp: 1234567890, ··· 78 73 backup_uri: "backup_video.mp4", 79 74 dubbing_info: [], 80 75 media_variants: [], 81 - } as VideoMedia, 76 + } as VideoMedia], 82 77 }; 83 78 84 79 const processor = new InstagramMediaProcessor([mockPost], mockArchiveFolder); ··· 86 81 87 82 expect(result).toHaveLength(1); 88 83 expect(result[0].postText).toBe("Test Video Post"); 89 - expect(Array.isArray(result[0].embeddedMedia)).toBe(false); 84 + expect(Array.isArray(result[0].embeddedMedia)).toBe(true); 90 85 }); 91 86 }); 92 87 ··· 130 125 }]; 131 126 132 127 const processor = new InstagramImageProcessor(mockImages, "/test/archive"); 128 + const result = await processor.process(); 133 129 134 - await expect(processor.process()).rejects.toThrow("Unsupported file type"); 130 + expect(result).toHaveLength(1); 131 + expect(result[0].mimeType).toBe(""); 135 132 }); 136 133 }); 137 134 ··· 148 145 media_variants: [], 149 146 }; 150 147 151 - const processor = new InstagramVideoProcessor(mockVideo, "/test/archive"); 148 + const processor = new InstagramVideoProcessor([mockVideo], "/test/archive"); 152 149 const result = await processor.process(); 153 150 154 - expect(result.mimeType).toBe("video/mp4"); 155 - expect(result.mediaText).toBe("Test Video"); 151 + expect(result).toHaveLength(1); 152 + expect(result[0].mimeType).toBe("video/mp4"); 153 + expect(result[0].mediaText).toBe("Test Video"); 156 154 }); 157 155 158 156 test("should handle unsupported video types", async () => { ··· 167 165 media_variants: [], 168 166 }; 169 167 170 - const processor = new InstagramVideoProcessor(mockVideo, "/test/archive"); 171 - 172 - await expect(processor.process()).rejects.toThrow("Unsupported file type"); 168 + const processor = new InstagramVideoProcessor([mockVideo], "/test/archive"); 169 + const result = await processor.process(); 170 + 171 + expect(result).toHaveLength(1); 172 + expect(result[0].mimeType).toBe(""); 173 173 }); 174 174 }); 175 175 176 - describe("Custom MediaProcessorFactory", () => { 177 - test("should allow custom processor injection", async () => { 178 - const mockFactory = { 179 - createProcessor: jest.fn().mockImplementation((media) => ({ 180 - process: jest.fn().mockResolvedValue([{ 181 - mediaText: "Mock Result", 182 - mimeType: "mock/type", 183 - mediaBuffer: Buffer.from("test") 184 - }]) 185 - })) 186 - }; 187 - 176 + describe("MediaProcessorFactory", () => { 177 + test("should use DefaultMediaProcessorFactory for image processing", async () => { 188 178 const mockPost: InstagramExportedPost = { 189 179 creation_timestamp: 1234567890, 190 180 title: "Test Post", ··· 200 190 ] as ImageMedia[], 201 191 }; 202 192 203 - const processor = new InstagramMediaProcessor([mockPost], "/test/archive", mockFactory); 193 + const processor = new InstagramMediaProcessor([mockPost], "/test/archive"); 194 + const result = await processor.process(); 195 + 196 + expect(result).toHaveLength(1); 197 + expect(result[0].embeddedMedia).toBeDefined(); 198 + expect(Array.isArray(result[0].embeddedMedia)).toBe(true); 199 + }); 200 + 201 + test("should use DefaultMediaProcessorFactory for video processing", async () => { 202 + const mockPost: InstagramExportedPost = { 203 + creation_timestamp: 1234567890, 204 + title: "Test Video Post", 205 + media: [ 206 + { 207 + uri: "test.mp4", 208 + title: "Test Video", 209 + creation_timestamp: 1234567890, 210 + media_metadata: {}, 211 + cross_post_source: { source_app: "Instagram" }, 212 + backup_uri: "backup_test.mp4", 213 + dubbing_info: [], 214 + media_variants: [], 215 + }, 216 + ] as VideoMedia[], 217 + }; 218 + 219 + const processor = new InstagramMediaProcessor([mockPost], "/test/archive"); 204 220 const result = await processor.process(); 205 221 206 - expect(mockFactory.createProcessor).toHaveBeenCalled(); 207 222 expect(result).toHaveLength(1); 208 223 expect(result[0].embeddedMedia).toBeDefined(); 224 + expect(Array.isArray(result[0].embeddedMedia)).toBe(true); 209 225 }); 210 226 }); 211 227 });
+47 -40
src/media/media.ts
··· 1 1 import FS from "fs"; 2 2 3 3 import { logger } from "../logger/logger"; 4 - import { validateVideo } from "../video/video"; 4 + import { getMimeType as getVideoMimeType, validateVideo } from "../video/video"; 5 5 import { ProcessedPost, ProcessedPostImpl } from "./ProcessedPost"; 6 6 import { 7 7 MediaProcessResult, ··· 14 14 Media, 15 15 VideoMedia, 16 16 } from "./InstagramExportedPost"; 17 + import { getImageMimeType } from "../image"; 17 18 // TODO make a stratgey pattern for video versus image 18 19 const MAX_IMAGES_PER_POST = 4; 19 20 const POST_TEXT_LIMIT = 300; 20 21 const POST_TEXT_TRUNCATE_SUFFIX = "..."; 21 - const UNSUPPORTED_FILE_TYPE_ERROR = Error(`Unsupported file type`); 22 22 23 23 /** 24 24 * Strategy pattern interface to allow all medias and posts to share a common method of process. ··· 59 59 * Processes single video post media into a normalized MediaProcessResult. 60 60 */ 61 61 interface VideoMediaProcessingStrategy 62 - extends ProcessStrategy<MediaProcessResult>, 62 + extends ProcessStrategy<MediaProcessResult[]>, 63 63 MIMEType {} 64 64 65 65 export class InstagramMediaProcessor implements InstagramPostProcessingStrategy { ··· 87 87 const processingPosts: Promise<ProcessedPost>[] = []; 88 88 89 89 for (const post of this.instagramPosts) { 90 - const postDate = new Date(post.creation_timestamp * 1000); 90 + const timestamp = post.creation_timestamp || post.media[0].creation_timestamp; 91 + const postDate = new Date(timestamp * 1000); 91 92 const processingPost = new ProcessedPostImpl(postDate, post.title); 92 93 93 94 // Get appropriate strategy from factory ··· 128 129 } 129 130 130 131 public getMimeType(fileType: string): string { 131 - switch (fileType.toLowerCase()) { 132 - case "heic": 133 - return "image/heic"; 134 - case "webp": 135 - return "image/webp"; 136 - case "jpg": 137 - return "image/jpeg"; 138 - default: 139 - logger.warn(`Unsupported File type ${fileType}`); 140 - throw UNSUPPORTED_FILE_TYPE_ERROR; 141 - } 132 + return getImageMimeType(fileType); 142 133 } 143 134 144 135 /** ··· 153 144 ): Promise<ImageMediaProcessResultImpl> { 154 145 const fileType = media.uri.substring(media.uri.lastIndexOf(".") + 1); 155 146 const mimeType = this.getMimeType(fileType); 156 - 157 147 const mediaBuffer = getMediaBuffer(archiveFolder, media); 158 148 159 149 let mediaText = media.title ?? ""; ··· 181 171 182 172 export class InstagramVideoProcessor implements VideoMediaProcessingStrategy { 183 173 constructor( 184 - public instagramVideo: VideoMedia, 174 + public instagramVideos: VideoMedia[], 185 175 public archiveFolder: string 186 176 ) {} 187 - process(): Promise<MediaProcessResult> { 188 - const processingVideo = this.processVideoMedia( 189 - this.instagramVideo, 190 - this.archiveFolder 191 - ); 192 - 193 - return processingVideo; 177 + process(): Promise<MediaProcessResult[]> { 178 + const processingResults: Promise<MediaProcessResult>[] = []; 179 + // Iterate over each video in the post, 180 + // adding the process to the promise array. 181 + for (const media of this.instagramVideos) { 182 + const processingVideo = this.processVideoMedia( 183 + media, 184 + this.archiveFolder 185 + ); 186 + processingResults.push(processingVideo); 187 + } 188 + // Return all images being processed as a single promise. 189 + return Promise.all(processingResults); 194 190 } 195 191 196 192 public getMimeType(fileType: string): string { 197 - switch (fileType.toLowerCase()) { 198 - case "mp4": 199 - return "video/mp4"; 200 - case "mov": 201 - return "video/quicktime"; 202 - default: 203 - logger.warn(`Unsupported File type ${fileType}`); 204 - throw UNSUPPORTED_FILE_TYPE_ERROR; 205 - } 193 + return getVideoMimeType(fileType); 206 194 } 207 195 208 196 /** ··· 222 210 223 211 const mediaBuffer = getMediaBuffer(archiveFolder, media); 224 212 225 - if(validateVideo(mediaBuffer!)) { 213 + if(!validateVideo(mediaBuffer!)) { 226 214 throw Error('Video too large.') 227 215 } 228 216 229 - return new VideoMediaProcessResultImpl(media.title, mimeType, mediaBuffer!); 217 + return Promise.resolve(new VideoMediaProcessResultImpl(media.title, mimeType, mediaBuffer!)); 230 218 } 231 219 } 232 220 ··· 288 276 289 277 // New factory interface 290 278 interface MediaProcessorFactory { 291 - createProcessor(media: Media | Media[], archiveFolder: string): ProcessStrategy<MediaProcessResult | MediaProcessResult[]>; 279 + createProcessor(media: Media | Media[], archiveFolder: string): ProcessStrategy<MediaProcessResult[]>; 280 + 281 + /** 282 + * returns if any of the media is a video. 283 + * @param media 284 + */ 285 + hasVideo(media: Media[]) 292 286 } 293 287 294 - // Default factory implementation 288 + /** 289 + * Processor factory that handles images and video. 290 + */ 295 291 class DefaultMediaProcessorFactory implements MediaProcessorFactory { 296 - createProcessor(media: Media | Media[], archiveFolder: string): ProcessStrategy<MediaProcessResult | MediaProcessResult[]> { 297 - if (Array.isArray(media)) { 292 + createProcessor(media: Media | Media[], archiveFolder: string): ProcessStrategy<MediaProcessResult[]> { 293 + if (Array.isArray(media) && !this.hasVideo(media)) { 298 294 return new InstagramImageProcessor(media, archiveFolder); 299 295 } 300 - return new InstagramVideoProcessor(media as VideoMedia, archiveFolder); 296 + return new InstagramVideoProcessor(media as VideoMedia[], archiveFolder); 297 + } 298 + 299 + hasVideo(media: Media[]) { 300 + let hasVideo = false; 301 + for(const file of media) { 302 + const fileType: string = file.uri.substring(file.uri.lastIndexOf(".") + 1); 303 + const mimeType = getVideoMimeType(fileType); 304 + hasVideo = mimeType.includes('video/'); 305 + } 306 + 307 + return hasVideo; 301 308 } 302 309 }
+1 -1
src/video/index.ts
··· 1 - export * from './video.js'; 1 + export * from './video';
+36 -13
src/video/video.ts
··· 1 - import ffmpeg from 'fluent-ffmpeg'; 2 - import ffprobe from '@ffprobe-installer/ffprobe'; 3 - import { logger } from '../logger/logger' 4 - 1 + import ffmpeg from "fluent-ffmpeg"; 2 + import ffprobe from "@ffprobe-installer/ffprobe"; 3 + import { logger } from "../logger/logger"; 5 4 // Configure ffmpeg to use ffprobe 6 5 ffmpeg.setFfprobePath(ffprobe.path); 7 6 ··· 11 10 */ 12 11 export function validateVideo(buffer: Buffer): boolean { 13 12 const MAX_SIZE = 100 * 1024 * 1024; // 100MB 14 - logger.debug(`Validating video size: ${Math.round(buffer.length / 1024 / 1024)}MB`); 13 + logger.debug( 14 + `Validating video size: ${Math.round(buffer.length / 1024 / 1024)}MB` 15 + ); 15 16 if (buffer.length > MAX_SIZE) { 16 - logger.warn(`Video file too large: ${Math.round(buffer.length / 1024 / 1024)}MB (max ${MAX_SIZE / 1024 / 1024}MB)`); 17 + logger.warn( 18 + `Video file too large: ${Math.round( 19 + buffer.length / 1024 / 1024 20 + )}MB (max ${MAX_SIZE / 1024 / 1024}MB)` 21 + ); 17 22 return false; 18 23 } 19 24 return true; ··· 23 28 * Uses FFMpeg to resolve the video dimensions. 24 29 * @returns Promise<{width: number, height: number}> 25 30 */ 26 - export async function getVideoDimensions(filePath: string): Promise<{width: number, height: number}> { 31 + export async function getVideoDimensions( 32 + filePath: string 33 + ): Promise<{ width: number; height: number }> { 27 34 logger.debug(`Getting video dimensions for: ${filePath}`); 28 35 return new Promise((resolve, reject) => { 29 36 ffmpeg.ffprobe(filePath, (err: Error, metadata) => { ··· 32 39 reject(err); 33 40 return; 34 41 } 35 - 36 - const videoStream = metadata.streams.find(s => s.codec_type === 'video'); 42 + 43 + const videoStream = metadata.streams.find( 44 + (s) => s.codec_type === "video" 45 + ); 37 46 if (!videoStream) { 38 - logger.error('No video stream found in file'); 39 - reject(new Error('No video stream found')); 47 + logger.error("No video stream found in file"); 48 + reject(new Error("No video stream found")); 40 49 return; 41 50 } 42 51 43 52 const dimensions = { 44 53 width: videoStream.width || 640, 45 - height: videoStream.height || 640 54 + height: videoStream.height || 640, 46 55 }; 47 - logger.debug(`Video dimensions: ${dimensions.width}x${dimensions.height}`); 56 + logger.debug( 57 + `Video dimensions: ${dimensions.width}x${dimensions.height}` 58 + ); 48 59 resolve(dimensions); 49 60 }); 50 61 }); 51 62 } 63 + 64 + export function getMimeType(fileType: string): string { 65 + switch (fileType.toLowerCase()) { 66 + case "mp4": 67 + return "video/mp4"; 68 + case "mov": 69 + return "video/quicktime"; 70 + default: 71 + logger.warn(`Unsupported Video File type ${fileType}`); 72 + return ""; 73 + } 74 + }