Import Instagram archive to a Bluesky account
9
fork

Configure Feed

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

Split posts creating offset per posts timestamp so they are not deduplicated.

+87 -6
+1
.env.dist
··· 10 10 TEST_VIDEO_MODE=0 11 11 TEST_IMAGE_MODE=0 12 12 TEST_IMAGES_MODE=0 # 5 images in a post (only 4 should upload) 13 + TEST_MIXED_MEDIA_MODE=0 # 5 images in a post (uploads two posts, one with 4 and a second with 1) 13 14 # Logging level 14 15 LOG_LEVEL=info
+11 -5
src/config.ts
··· 11 11 private readonly testVideoMode: boolean; 12 12 private readonly testImageMode: boolean; 13 13 private readonly testImagesMode: boolean; 14 + private readonly testMixedMediaMode: boolean; 14 15 private readonly simulate: boolean; 15 16 private readonly minDate: Date | undefined; 16 17 private readonly maxDate: Date | undefined; ··· 22 23 testVideoMode: boolean; 23 24 testImageMode: boolean; 24 25 testImagesMode: boolean; 26 + testMixedMediaMode: boolean; 25 27 simulate: boolean; 26 28 minDate?: Date; 27 29 maxDate?: Date; ··· 32 34 this.testVideoMode = config.testVideoMode; 33 35 this.testImageMode = config.testImageMode; 34 36 this.testImagesMode = config.testImagesMode; 37 + this.testMixedMediaMode = config.testMixedMediaMode; 35 38 this.simulate = config.simulate; 36 39 this.minDate = config.minDate; 37 40 this.maxDate = config.maxDate; ··· 48 51 testVideoMode: process.env.TEST_VIDEO_MODE === '1', 49 52 testImageMode: process.env.TEST_IMAGE_MODE === '1', 50 53 testImagesMode: process.env.TEST_IMAGES_MODE === '1', 54 + testMixedMediaMode: process.env.TEST_MIXED_MEDIA_MODE === '1', 51 55 simulate: process.env.SIMULATE === '1', 52 56 minDate: process.env.MIN_DATE ? new Date(process.env.MIN_DATE) : undefined, 53 57 maxDate: process.env.MAX_DATE ? new Date(process.env.MAX_DATE) : undefined, 54 - blueskyUsername: process.env.BLUESKY_USERNAME || '', 55 - blueskyPassword: process.env.BLUESKY_PASSWORD || '', 56 - archiveFolder: process.env.ARCHIVE_FOLDER || '' 58 + blueskyUsername: process.env.BLUESKY_USERNAME ?? '', 59 + blueskyPassword: process.env.BLUESKY_PASSWORD ?? '', 60 + archiveFolder: process.env.ARCHIVE_FOLDER ?? '' 57 61 }); 58 62 } 59 63 ··· 61 65 * Checks if any test mode is enabled 62 66 */ 63 67 isTestModeEnabled(): boolean { 64 - return this.testVideoMode || this.testImageMode || this.testImagesMode; 68 + return this.testVideoMode || this.testImageMode || this.testImagesMode || this.testMixedMediaMode; 65 69 } 66 70 67 71 /** ··· 108 112 if (this.testVideoMode) return path.join(rootDir, 'transfer/test_video'); 109 113 if (this.testImageMode) return path.join(rootDir, 'transfer/test_image'); 110 114 if (this.testImagesMode) return path.join(rootDir, 'transfer/test_images'); 115 + if (this.testMixedMediaMode) return path.join(rootDir, 'transfer/test_mixed_media'); 111 116 return this.archiveFolder; 112 117 } 113 118 ··· 119 124 const enabledModes = Object.entries({ 120 125 testVideoMode: this.testVideoMode, 121 126 testImageMode: this.testImageMode, 122 - testImagesMode: this.testImagesMode 127 + testImagesMode: this.testImagesMode, 128 + testMixedMediaMode: this.testMixedMediaMode 123 129 }) 124 130 .filter(([_, enabled]) => enabled) 125 131 .map(([mode]) => mode);
+70
src/media/media.test.ts
··· 488 488 expect(result[3].embeddedMedia).toHaveLength(1); 489 489 expect(result[3].embeddedMedia[0].mimeType).toBe("video/mp4"); 490 490 }); 491 + 492 + test("should ensure split posts have different timestamps to prevent Bluesky duplicates", async () => { 493 + const mockPost: InstagramExportedPost = { 494 + creation_timestamp: 1720384531, 495 + title: "Test post with multiple media", 496 + media: [ 497 + { 498 + uri: "photo1.jpg", 499 + title: "", 500 + creation_timestamp: 1720384529, 501 + media_metadata: { 502 + camera_metadata: { 503 + has_camera_metadata: false 504 + } 505 + }, 506 + cross_post_source: { source_app: "FB" }, 507 + backup_uri: "backup1.jpg", 508 + } as ImageMedia, 509 + { 510 + uri: "photo2.jpg", 511 + title: "", 512 + creation_timestamp: 1720384529, 513 + media_metadata: { 514 + camera_metadata: { 515 + has_camera_metadata: false 516 + } 517 + }, 518 + cross_post_source: { source_app: "FB" }, 519 + backup_uri: "backup2.jpg", 520 + } as ImageMedia, 521 + { 522 + uri: "video1.mp4", 523 + title: "", 524 + creation_timestamp: 1720384529, 525 + media_metadata: { 526 + camera_metadata: { 527 + has_camera_metadata: false 528 + } 529 + }, 530 + cross_post_source: { source_app: "FB" }, 531 + backup_uri: "backup_video1.mp4", 532 + dubbing_info: [], 533 + media_variants: [], 534 + } as VideoMedia, 535 + ], 536 + }; 537 + 538 + const processor = new InstagramMediaProcessor([mockPost], mockArchiveFolder); 539 + const result = await processor.process(); 540 + 541 + // Should create 2 posts (1 for images, 1 for video) 542 + expect(result).toHaveLength(2); 543 + 544 + // Verify timestamps are different and increment by 1 second 545 + const baseTimestamp = result[0].postDate!.getTime(); 546 + for (let i = 1; i < result.length; i++) { 547 + const currentTimestamp = result[i].postDate!.getTime(); 548 + const previousTimestamp = result[i-1].postDate!.getTime(); 549 + 550 + // Each post should be 1 second after the previous 551 + expect(currentTimestamp - previousTimestamp).toBe(1000); 552 + 553 + // Should be incrementally later than base timestamp 554 + expect(currentTimestamp - baseTimestamp).toBe(i * 1000); 555 + } 556 + 557 + // Verify post numbering 558 + expect(result[0].postText).toContain("(Part 1/2)"); 559 + expect(result[1].postText).toContain("(Part 2/2)"); 560 + }); 491 561 }); 492 562 493 563 describe("InstagramImageProcessor", () => {
+5 -1
src/media/processors/InstagramMediaProcessor.ts
··· 42 42 ): Promise<ProcessedPost[]> { 43 43 const posts: ProcessedPost[] = []; 44 44 const timestamp = originalPost.creation_timestamp || originalPost.media[0].creation_timestamp; 45 - const postDate = new Date(timestamp * 1000); 45 + const basePostDate = new Date(timestamp * 1000); 46 46 47 47 // Split images into chunks of MAX_IMAGES_PER_POST 48 48 const imageChunks: ImageMedia[][] = []; ··· 72 72 title += suffix; 73 73 } 74 74 75 + // Add a small time offset for each post (1 second) 76 + const postDate = new Date(basePostDate.getTime() + (currentPostNumber - 1) * 1000); 75 77 const post = new ProcessedPostImpl(postDate, title); 76 78 const mediaProcessor = this.mediaProcessorFactory.createProcessor( 77 79 imageChunk as ImageMedia[], ··· 103 105 title += suffix; 104 106 } 105 107 108 + // Add a small time offset for each post (1 second) 109 + const postDate = new Date(basePostDate.getTime() + (currentPostNumber - 1) * 1000); 106 110 const post = new ProcessedPostImpl(postDate, title); 107 111 const mediaProcessor = this.mediaProcessorFactory.createProcessor( 108 112 [video] as VideoMedia[],